DAKPPOの日記

技術ブログの体を装った日記

Instrument As A Space制作記

f:id:aknor141:20210411094016p:plain
プロジェクトの構成

cluster民のみなさま、こんにちは。

そしてcluster大加速祭2021 ワールド制作チャレンジに参加したみなさま、おつかれさまでした。24時間を存分に楽しむことができましたでしょうか。私はとても楽しかったです。

『未来の○○』というテーマのもと、自分なりの解釈をワールドとして形にできたので、とても満足しました。また、ありがたいことに制作したワールドを癒やされ部門にノミネートしていただきました。身に余る光栄、嬉しい限りです。

制作者が自分しか知り得ないような文脈をごちゃごちゃと付与したわりに、説明不足の感が否めないワールドになってしまいました。そこで、ワールドを作ったときに考えたことや工夫したことを文章にまとめておきたいと思います。未来の自分自身、仕組みに関心がおありの他のクリエイターの方、訪問したけどちょっとモヤッとした方などの一助となれば幸いです。

どんなワールド?

cluster.mu

  • 空間そのものが、音を奏でるためにつくられています。
  • ただし、生み出される音をプレイヤーがコントロールするすべはありません。音の石を投げ入れて以降、すべては偶発的に進行します。

ワールド名ですが、略してIaaSです。分かる人には分かるはず。

コンセプト

これは未来の楽器です。

以下の内容は私の想像です。歴史的根拠は皆無です。

f:id:aknor141:20210410182926p:plain
過去→現代

人工的に欲しい音を鳴らす道具である「楽器」が現れるより昔、人類にとって音楽は偶発的なものでした。当時の人たちは、自然の中で偶然発生した音の中に心地よいと感じる何らかの秩序を見出していたはずです。

  • 川が流れる音
  • 風が木の葉を揺らす音
  • 鳥がさえずる音 etc...

一方、音を制御する手段を手に入れた現代の我々は、楽器やコンピュータを用いて好きな音を鳴らしたり、それらを組み合わせて音楽を奏でたりできるようになりました。もはや環境の偶発性に頼らずとも、音楽を自在に楽しめるようになったわけです。

f:id:aknor141:20210410183041p:plain
現代→未来

さて、未来の我々はどのように音を楽しむのでしょう?音を制御し尽くした人類が次に手にするのは、「音が発生する環境そのものを自在に設計する能力」ではないでしょうか。(現に、今回の大加速祭では100以上のワールドが新たに誕生しています!)

今回は、そんな音が発生する環境の一例として、かつて人類にとって音楽の全てであった「偶発的に音楽が発生する空間」をつくってみることにしました。

偶発性、どこ?

このワールドに込めた偶発性要素は主に以下の3つです。

偶発性要素1:音の選択

f:id:aknor141:20210410183143p:plain
鳴ってる音はCメジャースケールのダイアトニックコード(CM7 Am7 Em7 FM7 Dm7 G7 FonG の7つ。 Bb5入れ忘れてました…)とサブドミナントマイナー(Fm7 Dm7b5 AbM7の3つ)

本来コード進行にはある程度ルールがあるのですが、このワールドでは上記の10個の和音がランダムに再生されます 。したがって、有名な進行をいくら望んだとしても実現するか否かは運次第ですが、たまに絶妙な位置にサブドミナントマイナーが入ったりしてオッという気持ちになります。そうやって楽しむことが想定されたワールドです。

偶発性要素2:音が止まるタイミング

f:id:aknor141:20210410183354p:plain
石が落下すると音が終わる

石が跳ねる方向もランダムです。8方向のうち、直前と同じ方向を除いた7方向から等しい確率で選ばれます。石がステージから落ちると音が止まるので、音が途絶えるタイミングも制御しづらいようになっています。

偶発性要素3:波紋

f:id:aknor141:20210410193242p:plain
波紋が重なって複雑な模様を形成している様子

石が跳ねる位置がランダムに決まるので、水面に現れる波紋が形作る模様もランダムです。また、短時間で消える方の波紋の輪の半径も実はランダムです。同じ模様が再び現れる確率はほぼゼロです。

ちなみに音の選択や石が跳ねる方向の選択は石の落下位置などに依存していません。。仮に全く同じ座標に同じ速度で石を投げ入れたとしてもランダムに音が鳴り、ランダムに石が跳ねます。

仕組み

f:id:aknor141:20210410194211p:plain
水にOnCollideItemTriggerをセットし、石が衝突したときにHitGroundというSignalを送信。これを契機にいろいろやっています。

石が跳ねる仕組み

f:id:aknor141:20210410194259p:plain
音の石のPrefab

HitGround受信時、石が消滅すると同時に次の石が生成されています。その際、出現地点が水面よりも高い位置になるように調整すると、たとえば出現した石が即Collide判定を受けて次々と石が生成されるみたいな事故を防ぐことができます。

また、次の石の出現位置を鉛直上方向に固定するため、RotationConstraintを用いてstaticなオブジェクトと結びつけ、石の回転を抑制しています。

f:id:aknor141:20210410194320p:plain
ItemTriggerLotteryで跳ねる音の石をランダムに選択している

生成した石は、オリジナルの石のPrefabVariantです。OnCreateItemTriggerでAddInstantForceItemを叩き、水面から跳ねるような動作をさせています。力の方向を8方向に設定した石をそれぞれ用意し、ItemTriggerLotteryでこのうちひとつを選択しています。

石がRespawnする仕組み

f:id:aknor141:20210410194422p:plain
RespawnStoneシグナルで石をWarpさせている

ステージ外に落ちた石はWarpItemGimmickで初期地点の塔の上方に移動させています。オリジナルの石とPrefabVariantで作成した石の違いはCreate時に力を加える処理だけなので、それが動作したあとならオリジナルの石と同じように扱っても問題ありません。

音がなる仕組み

f:id:aknor141:20210410194526p:plain
音を鳴らしているオブジェクトのPrefab

HitGround受信時、音を鳴らすためのオブジェクトを生成しています。ItemTriggerLotteryでどのコードを鳴らすか決めてます。ここでもPrefabVariantを使って、おおもとのオブジェクトの編集内容が同期するようにしています。また、音が十分鳴り終わったタイミングでDestroyさせています。

波紋が現れる仕組み

f:id:aknor141:20210411092256p:plain
波紋の成長過程

HitGround受信時、波紋を表現したParticleSystemをもつオブジェクトを2種類生成しています。ParticleSystemの設定では、サイズをぶわーっと上げつつ透明にしたりしています。

  • 短時間で消える3重リング
  • 長時間残る1重リング

鳴らしている和音が全て四和音だったので、リングの数は合計4つにしました。ついでに、GlobalにもHitGroundを飛ばして雲からも波紋を出しております。(蛇足だったかな。。)

謝辞

いつか自分の作ったものが何かに引っかかったら、そのときはまとめ記事の中に謝辞を書きたい、と心に決めておりました。お世話になったすべての方を記すときりがないので、作品の完成に特に寄与したと私の方で判断した方のみとさせていただいております。

@t_furuさん

zenn.dev

今回はPrefabVariantを非常に多用する制作となりました。clusterワールド制作ゆるゆる勉強会でt_furuさんがPrefabVariantについて言及していなければ、おそらくイベントのアップロード期限に間に合わなかったことでしょう。技術的知見の共有、いつも大変助かっております。(私も見習わなければ、、、)

@tanuki_realityさん

cluster.mu

石や雲が発している波紋をパーティクルで作ろうと思ったのは、たぬきちさんの「パーティクルエフェクトの作り方」講座でパーティクルでできることの豊富さや可能性を体感したのがきっかけだったりします。もし講座に遊びに行ってなければ、苦し紛れの脳筋アニメーションで何とかしようとして、多くの時間を費やしたんじゃないかなと思います。

@heputaaan777さん

cluster.mu

実は、いちこんピアノコンサートで語っていた「空間が音楽に同期するような音楽体験」というアイデアがこのワールドの着想の火種になってたりします。手を動かさずコンセプトを考えるだけの時間が短く済んだのは、アイデアの足がかりがあったおかげだなと振り返って思います。

@sakurasaku_1223さん

cluster.mu * Realityは過去の配信リンクに404を返すようなのでコチラ

土曜日の作業配信助かりました。たまたま作業がダレそうなタイミングだったんですが、そういうときに並走してる人がいるのは励みになりますね。新しいプラットフォームや踏み込んだことのない領域に果敢に挑んでいく姿勢、尊敬します。

その他、cluster社をはじめ数多くの人にお世話になりました。巡り合わせに感謝。

余談:

石掴まれとるやないかい高校校歌


石掴まれとるやないかい 偶発性とは何だったのか

石掴まれとるやないかい Grabbableではあるけれども

テストプレイで気づかないとか cluster下手とちゃうんか

石掴まれとるやないかい 石掴まれとるやないかい

ああ我ら 石掴まれとるやないかい高校

special thx: You Aoiさん

使用アセット:

  • All Sky Free - Cartoon Base BlueSky
  • RAKURAIさんの水シェーダー

『RayTracing撮影会場』制作記

cluster民のみなさま、こんにちは。 2021/2/10に『RayTracing撮影会場』というワールドをアップロードしました。

cluster.mu

  • さまざまな色のオブジェクト群をスクリーンに投影することができます。
  • 撮影するオブジェクトを掴んで自由に移動させることができます
  • スクリーンを移動させることもできます

試行錯誤の過程を後の自分が振り返れるように、作ったときに工夫したことや考えたことをまとめます。

NOTE:手探りの試行錯誤で見つけたやりかたなので、ベストプラクティスでない箇所が多々あるかと思います。

作成の動機

Clusterで使える機能だけでレイトレーシングっぽいことができるのでは?という予感があったので、実際できるのか試してみました。また、前回作ったワールドがBlender上での工夫をたくさん要するものだったので、次はUnityでの工夫を要するワールドを作りたかったというのもあります。

ところでレイトレーシングって何?

レイトレーシング - Wikipedia

光が伝搬する方向を逆順にたどることでシーンを描画する手法。カメラからピクセルに向かってレイを発射し、そのままピクセルを通過させることで、レイをシーン中に打ち出します。シーン中のオブジェクトに接触したレイはその交点からさらに反射・屈折・透過などを繰り返しながらシーン中を伝搬し、対応するピクセルの最終的な色を決定します。

制作したワールドについて、レイは衝突したオブジェクトの色をスクリーンに送っているだけです。オブジェクト表面での作用まで再現しているわけではないので、『レイトレーシング撮影会場』はちょっと誇大広告が過ぎたかなと反省しております。

注:もっとちゃんとしたレイトレーシングを実装したい方はPeter Shirleyによるこちらのシリーズをどうぞ

Ray Tracing in One Weekend Series

処理の概要と用語の整理

f:id:aknor141:20210321171103p:plain
描画する際の処理の概要

カメラの置いてある位置にsourceを配置し、ここからpixelの座標目掛けてrayを発射しています。rayが被写体オブジェクトに接触すると、対応するpixelの色が被写体オブジェクトに設定された値に変化します。

色を検知してピクセルを変化させる仕組み

発射したrayを介して被写体オブジェクトからpixelに色の情報を送っています。この処理は2つの部分から成ります。

被写体オブジェクト → ray : 色の判別

f:id:aknor141:20210321171328p:plain
被写体オブジェクトに付与したOnCollideItemTriggerの中身

被写体オブジェクトにOnCollideItemTriggerを設定し、TargetをCollideItemOrPlayerに設定します。衝突したrayに、目的の色と対応した整数型の値(以後int_colorと表記)を送信します。また、このとき同時にrayをrespawnさせる必要があるので、そのためのSignalも送ります。

ray → pixel : 色の反映

f:id:aknor141:20210321171439p:plain
pixelに付与したSetAnimatorValueGimmickの中身

f:id:aknor141:20210321171517p:plain
色を変更するAnimationControllerの状態遷移図
pixelにSetAnimationValueGimmickを設定。Itemにrayを指定することでray宛に送信されるint_colorを検知し、pixelのAnimationControllerに送ります。AnimationController内では、int_colorの値に応じてアニメーションを分岐させ、それぞれ対応する色に変化させています。 なお、色をリセットするときはGlobalLogicに0を送ってDefaultに戻しています。


結果的に色の判別部分と色の反映部分を分離することができました。これよって後の変更に強くなることが期待されます。例えば

  • 新しい被写体オブジェクトを追加したい → 形を作り、欲しい色のint_colorを送る。既存の色を使用するならpixel側の編集は不要。
  • 新しい色を追加したい -> Animationを増やしてAnimationControllerに設定する。被写体オブジェクトのことは一旦忘れてOK。

特定のpixel目掛けてrayを飛ばす仕組み

f:id:aknor141:20210321171733g:plain
sourceのローカル座標がpixelの位置に応じて回転する様子
sourceにAimConstraintを設定し、対応するpixelの方向を向かせています。これにより、sourceのローカル座標がpixelの位置に応じて回転するようになりました。
f:id:aknor141:20210321171835p:plain
rayに付与したSetVelocityItemGimmickの中身
この状態で、rayをsourceのローカル座標y軸に目掛けて発射します。RigidbodyのUseGravityを外した上でSetVelocityItemGimmickを適用することで直線的にrayを飛ばすことができます。これで、pixelの位置にかかわらず適切な方向にrayが飛ぶようになりました。

pixel + ray + sourceをPrefab化して大量に配置する

f:id:aknor141:20210321172011p:plain
シーンに存在する描画にかかわるオブジェクトたち
スクリーン上のpixelは32x32マスにしたかったのですが、動作が重くなりすぎちゃったので32x24マスにしました。したがってこのワールドには32x24個のpixel + ray + sourceが存在します。pixel + ray + sourceは互いに一対一に対応しているので、まとめて1つのPrefabとすることで簡単に複製できるようしました(これを描画オブジェクトとします)。これを実現するにあたり「絶対にひとつひとつパラメータを設定したくない!」と思ったので、以下の工夫を行いました。

NOTE:以下の図で描画オブジェクトが「Pixel」という名前になっていて紛らわしいですが、歴史的経緯によるものです。ご容赦ください。

階層性の利用

f:id:aknor141:20210321172123p:plain
Hierarchy内に見られるScreen, Row, Pixelの入れ子構造
32x24個のpixelをフラットに扱う場合、たとえばpixelを1行追加したい or 減らしたいなどの際に数十個の描画オブジェクトをpixelの座標を調整した上で増やす or 減らす必要があります。これは手間です。そこで、pixelを横一列に並べた32個の描画オブジェクトをRowという名前でPrefab化し、これを24個縦に並べる形態をとることにしました。これで、スクリーンを垂直方向に伸ばす場合はRowを追加すればよく、水平方向に伸ばす場合はRowの中の描画オブジェクトの数を増やせばよくなりました。

なお、この構造をとることによって難しくなる変更として、「左下の隅を四角く切り取る」などがありますが、当面スクリーンは四角形を予定しているので問題なさそうです。

f:id:aknor141:20210321172302p:plain
Pixel内のオブジェクト構造

描画オブジェクト自体のTransformを変更すると、sourceの位置などがずれて具合が悪いです。したがって、描画オブジェクトの他の要素はそのままに、pixelの座標のみ変更する必要があります。描画オブジェクトを追加した上でHierarchyから該当するpixelをCtrl + クリックで複数選択し、ちまちま動かすのでもできなくはないですが、以下のUnity拡張を応用すればこのpixelの選択を効率化することができます。

github.com

NOTE:以下の内容はCreatorKitTriggerEditor v0.1.2で確認済。最新版のv0.1.3でも多分同じことができます。

CreatorKitTriggerEditorでは次のことができます。

f:id:aknor141:20210321172546p:plain
1. 現在のSceneで使用されているトリガー・ギミックのkeyを全取得して一覧表示
f:id:aknor141:20210321172626p:plain
2. keyを選択することで、そのkeyを使用しているcomponentをすべて選択

この機能を応用すれば、「Prefab内の特定のオブジェクトをすべて選択する」ということが可能です。

  1. Prefab内の対象のオブジェクトにItemLogicなどを割り当て、適当なkeyを登録する
  2. StateKeyListWindowでRefresh Listしたのち、割り当てたkeyを選択

また、Refresh Listは押した時点で存在するオブジェクトしか紐付けを行わないようなので、描画オブジェクトを一行追加する際は次のようにすれば可能です。

  1. StateKeyListでRefresh List
  2. RowをDuplicate(このときRefreshListをしない!)
  3. StateKeyListからkeyを選択。このとき、Duplicateで新しくできたRowは選択されない点に注意
  4. よしなにTransform

f:id:aknor141:20210321172749g:plain
既存のRow内のpixelのみTransformされる様子。真ん中で浮いているRowがDuplicateによって追加されたもの。

なおかつ、描画オブジェクトやRowをひとつひとつ追加せず1->2->4->...->32と増やせば、10回の複製&移動操作で32x32ピクセルのスクリーンが完成します。

その他の工夫

ワールド内の移動しやすさのためにJoinPlayer時にSpeedとJumpを3倍にしています。

f:id:aknor141:20210321172931p:plain
Set**RatePlayerGimmickで移動しやすくする

至らなかったところ

描画オブジェクト、要る?

「Prefab内の一部のオブジェクトだけ動かしたい」という状況はもしかしたら避けられたかもしれません。source + ray + pixelのうち、pixelを分離することができればよかったんですが、効率よくやる方法がちょっと思いつきませんでした。

色を変更する処理の改善

動画と連動するライトをつくる|ほびわん|note に記載されているカラーピッカーを応用したら、色変化のアニメーションとか不要だったかもしれません。(ちゃんと読んで検証できてないので、かもしれない、としか言えないのですが)

スクリーンを移動させる処理で余計なことをした

スクリーンの位置は矢印ボタンで動かせるようにしました。各ピクセルにAnimationを設定し、ボタンからGlobalに移動方向を示す数値を送れば実現できそうですが、なんとなく次の方法を取ることにしました。

  • ピクセルの動きを司るオブジェクト(ScreenMarker)を設置
  • ピクセルにはPositionConstraintを設定し、ScreenMarkerと位置を同期させる
  • ScreenMarkerの移動は、SetAnimationValueGimmick + Root Motion: ONで実現。数字と移動方向を結びつける。
    • ただし、AnimationControllerParameterの値を都度もとに戻さないとScreenMarkerが無限に移動を続けることになる。そこで、移動させた0.2秒後にParameterをもとに戻すためのSetAnimationValueGimmickを打ってる。

この、「Constraintで位置を同期させる」という処理が裏目となって、ワールドの動作を重くする結果になりました。以下、実験用ワールドでの観測結果です。

ケース1:10,000個のオブジェクトにそれぞれ移動用Animationを割り当てた場合

f:id:aknor141:20210321173328p:plain
Animatorが設定されたUnitが10,000個ある状態
f:id:aknor141:20210321173410p:plain
9~10fpsくらい

ケース2:10,000個のオブジェクトにPositionConstraintを割り当て、移動用Animationを割り当てたDirectorオブジェクトをSourceに指定した場合

f:id:aknor141:20210321173442p:plain
Animatorが設定されたDirectorとConstraintが設定されたUnitC10,000個
f:id:aknor141:20210321173645p:plain
3fpsくらい

clusterワールド『天空ピアノ』制作記

2021/2/7に『天空ピアノ』というワールドをアップロードしました。

cluster.mu

訪問してくださったみなさま、ありがとうございます。訪問者数が3桁にのったのは私にとって初めてで、とても嬉しく誇らしい気持ちになりました。

あとで思い出して懐かしむために、このワールドを作成する上で考えたことや工夫したことをまとめておきたいと思います。

NOTE:

手探りで編み出したノウハウなので、ベストプラクティスではない箇所が多々あると思います。

コンセプト

f:id:aknor141:20210311220758p:plain ピアノをテーマにしつつも、表現したいことは視覚的なものが大半でした。

  • 巨大で荘厳な建築物
  • パーティクルを使った演出
  • Epic_BlueSunsetのSkyboxとそれに合わせた照明

この場合、もし実物大の鍵盤を配置すると、訪問者は滞在中のほとんどの時間鍵盤を凝視することになってしまい、せっかくの視覚表現をあまり見てもらえなくなるかもしれません。そこで、鍵盤自体をデフォルトアバターが乗れるサイズくらいまで大きくし、音を鳴らすときは鍵盤に飛び乗るようにしてみます。さらに、音が鳴ったタイミングでパーティクルを立ち上らせ、空を見上げるよう促してみます。

f:id:aknor141:20210311221022p:plain

配置する楽器は割と適当に決めました。巨大で荘厳なイメージに近かったのでパイプオルガン、放射状に棒が伸びてるとかっこいいかなと思ってバグパイプ、天空感を演出するために羽っぽいハープ。これらの楽器だけだと散らかった感じがしたので、天空っぽさのあるアーチをつけて全体を締めてみました。また、「絶対スピーカーいらんやろ」みたいな楽器とスピーカーが一緒になっているのが好きなので、鍵盤の両脇にデカめのスピーカーを配置。

f:id:aknor141:20210311221046p:plain

せっかく天空にピアノを配置したので、訪問者にはぜひステージ外に落ちてほしいところ。そこで、本来訪問者から見えないはずのステージ下側に雲を配置。落下中に眺めてもそこそこかっこよい風にしてみました。

Unity上での工夫

どちらかというとBlender上で工夫することが多い作品となりました。BlenderのTipsに触れるときりがなさそうなので、Unityに関連する工夫だけ述べることにします。

Fogで距離感を出す

f:id:aknor141:20210311222140p:plain
Fogの有無による比較。バグパイプのあたりの差が分かりやすいかも
建物の大きさをより表現するためにFogを使って空気遠近法を再現しました。Window -> Rendering -> LightingSettingsのSceneタブからFogの項目を選択し、チェックを入れます。ColorはSkyboxの青色をスポイトで抜いて設定してみました。

ParticleSimulation

f:id:aknor141:20210311224002g:plain
光の粒がパッと出たあと一定の速さまでスゥーーーっと減速する様子
ピアノの音を鳴らすと同時にParticleSystemをCreateしています。パーティクルはまだまだ勉強中なので、とりあえずInspectorの様子だけ掲載。何かを理解したあとに戻ってきて加筆するかもしれません。
f:id:aknor141:20210311224111p:plain
ParticleSystemタブ。粒が一斉に消えると変なのでStart Lifetimeを適当に散らす。

f:id:aknor141:20210311224155p:plain
Emission, Shape, Velocity over Lifetimeタブ。パッと出したかったのでBurst使用、鍵盤から出したかったのでShapeはBox、SpeedModifierにカーブを割り当てて速度減衰を表現。

f:id:aknor141:20210311224220p:plain
速度カーブの中身。最初の一瞬だけ速さのばらつきを大きくし、徐々に一定速度に収束させる。

f:id:aknor141:20210311224244p:plain
Renderタブ。とくにいじってなかった気がする。

f:id:aknor141:20210311224854p:plain
不要になったらDestroy

ワールド説明には「その音色は光の粒となって天に届くらしい」とありますが、途中でDestroyしているのでこれは嘘です。

ピアノの音を鳴らす

2021/02/02のハロークラスターで紹介されたピアノの仕組みを応用しています。

note.com

OnCollideItemTriggerでPlayAudioSourceGimmickにシグナルを送ります。ハロクラではCollisionTypeをTriggerに設定していましたが、本ケースではEverythingにしてあります。

アバターの形状によって音がならないケースがありました。もっと安定したやりかたがあるかもしれません。

至らなかったところ

ピアノの鍵を1つのfbxの中に全部入れちゃってたのですが、これは各鍵を独立のモデルとすべきでした。1つのfbx内に全部の鍵が入ってると鍵盤全体をPrefab化することになります。そのため、子オブジェクトの鍵にそれぞれギミックを割り当てる必要が生じ、これがとても手間です。

よりよいやり方として、ピアノの鍵のギミックのみをPrefab化し、その子に各鍵のオブジェクトを配置するという方法があります。この構造を取れれば、かなり時間を節約できたんじゃないかなと反省。

clusterゆるゆる勉強会にて、同様の問題にt_furuさんが言及しています。

www.youtube.com

このclusterゆるゆる勉強会オススメです。見たことない方はアーカイブを是非チェックしてみてください。

使ったアセット

  • AllSkyFree - Epic_BlueSunset
  • CC0Textures.com
    • Marble 012
    • Wood Floor 041
    • Paper 002
    • Metal 003
    • Fabric 036

Appendix

https://mmh.yafjp.org/mmh/about/img/photo-pipe01.jpg

パイプオルガンは横浜みなとみらいホールのものを参考にしました。

f:id:aknor141:20210311230235p:plain
実は見えないところで楽しております、、、、見えないですよね?

Cluster GAMEJAM 2020 in WINTER 『Entropy In The Room』制作記

Cluster GAMEJAM 2020 in Winter参加者のみなさま、お疲れさまでした。48時間を全力で楽しむことができましたでしょうか。わたしはとても楽しかったです。

これまでUnityやCluster Creator Kitを触ったことがなかったのですが、先人クリエイターたちの知恵を借りつつ、なんとか提出まで走りきれてよかったです。

コンテストへの初参加という体験は今回限りなので、ワールド作成の試行錯誤の中で自分なりに考えたことをまとめておこうと思います。

NOTE:

手探りで編み出したノウハウなので、ベストプラクティスではない箇所が多々あると思います。

あとマルチプレイのテストを忘れていたため、複数人プレイ時に思わぬ動作をするかもしれないです。

どんなワールド?

f:id:aknor141:20201224215521p:plain https://cluster.mu/w/1e380639-e9c0-4401-a3e6-4175486345cc

子供部屋を散らかすゲームです。制限時間180秒以内にできるだけ多くのものを掴み、そして離してください。

掴めるアイテムは全部で90個。同じアイテムで複数回掴む->離すを繰り返しても1点しか入りません。なので最大90点です。

コツ:

  • わざわざアイテムを離さなくても、アイテムを掴んだ状態で他のアイテムを掴むことで、もともと持っていたアイテムを離したことになります。これを利用すると一人プレイでも安定して90点取れます。

  • アイテムを手に持った状態で片付け用の扉とインタラクトすると、手元のアイテムのみリセットされません(バグ)。これを利用するとクローゼットの上に隠すように置いてあるドーナツを先に処理でき、スコアがより安定します。

いつでも投稿できる状態を維持する

f:id:aknor141:20201224192657p:plain
24時間経過時点で家具が一個もない様子

このゲームは特性上部屋の内装が非常に大事です。しかし、家具やアイテムの作成・配置は後回しにして、それ以外の重要な要素(ゲーム初期化処理、タイマー、UIなど)の作成および後述するThrowableオブジェクトの整備を真っ先に片付けました。これには以下のメリットがありました。

  • ワールド投稿締め切りに確実に間に合わせられる。家具や掴めるアイテムが少なくても、ゲームとして成り立っていれば投稿はできる。実際、アイテムを100個用意して100点満点としたかったところを90個に妥協している。
  • 時間をかければかけるだけ成果物がよくなるフェイズを作業がダレてくる中盤~終盤にもってくることができる。二日目はロジックで悩んだりせずにひたすら家具を配置し続けることができた。

Throwableオブジェクト

f:id:aknor141:20201224192856j:plain
Throwableオブジェクトと子オブジェクトのPrefab(分かりやすいようにオブジェクトの名前を変えてます)

掴めるアイテムは全てThrowableオブジェクトというラッパーの中にPrefabが入った構造となっています。

Throwableオブジェクトが担う役割は以下のとおりです。

  • GrabbableItemの付与
  • 離したときに前方に弾き飛ばすギミック
  • 最初に離したときだけスコアを加算するロジック
  • 残り時間が0秒のときのスコア加算を無視する&ゲームリセット時に再び加算するように戻すロジック
  • ゲームリセット時にアイテムをもとの位置にRespawnするギミック

f:id:aknor141:20201224214625j:plain
ThrowableオブジェクトのInspector。左から順に上記役割と対応

これらを各Prefabに逐一設定するのは大変ですが、このThrowableオブジェクトをAssets化することで、

  1. Throwableオブジェクトを配置
  2. 子オブジェクトにPrefabを配置
  3. 子のBoxCollider付与とサイズ調整
  4. Inspector画像に赤で示した3箇所を変更

という定型化した4ステップに圧縮することができました。これにより、作業に疲れてくる終盤でもほとんどミスすることなくたくさんのアイテムを配置できました。 作業の効率化の他にも、アイテムの形状に関わるComponentとゲームロジックに関わるComponentを分離できるというメリットもあります。

ゲーム内の工夫した処理

ものを手から離したときに弾き飛ばすロジック

f:id:aknor141:20201224214730p:plain

OnReleaseItemTriggerでAddInstantForceItemGimmickをトリガーします。座標系をThrowableの子オブジェクトとし、Z方向に10(ここはお好み)を指定することで、アイテムが手から離れた瞬間に弾き飛ぶようになります。

散らかしたアイテムの初期化

f:id:aknor141:20201224220049g:plain f:id:aknor141:20201224214815p:plain

ゲームでは、入り口のドアをインタラクトすると散らかしたアイテムがもとの位置に戻るようになっています。現状、アイテムを指定した座標に移動させるギミックやアイテムをRespawnさせるギミックといったものはなさそうだったため、WarpItemGimmickでDespawnHeightにワープさせることで擬似的にRespawnを実現しています。

リザルト画面の表示/非表示

f:id:aknor141:20201224220520g:plain

(cluster公式のクリエイターコミュニティ(Discord) にて質問した結果解決したものです。応対して下さったnatsu_sanさんとvinsさんに感謝!)

残り時間が0秒のときだけUIにリザルト画面を表示したかったのですが、setTextGimmickはオブジェクトにつき1つしかアサインできないため、「あるトリガーで『RESULT:{0}』をセットし、別のトリガーで空文字列をセット」といった方法はとれませんでした。そこで、SetGameObjectActiveGimmickを使用してリザルト用のSafeArea自体の表示/非表示を切り替えることで同様の機能を実現させました。

その他ちょっとした小技

f:id:aknor141:20201224215351p:plain

BoxColliderをオブジェクトに追加すると、通常はオブジェクトをちょうど含む大きさのボックスが生成されます。ところが、子オブジェクトをもつオブジェクトにBoxColliderを追加すると、ボックスの大きさが自動調節されず、各辺の長さが1のボックスが生成されてしまいます。この場合は、子オブジェクトの中からいいかんじのものをを見つけて、それにBoxColliderを追加&CopyComponentで親にペーストすると自力で調整する手間を省くことができます。

f:id:aknor141:20201224215407p:plain

一部アイテムを壁にかけたり吊るしたりしてるように見えますが、実は透明な台に乗せているだけです。

至らなかったところ

Globalの扱いが雑

たとえばGlobalのキーを操作できるオブジェクトは一箇所にまとめてしまい、各アイテムはGlobalトリガーをSignalや定数で投げるだけ、みたいにした方が、ゲームシステムに関わる処理の記述が分散しなくてよさげな気がしました。たとえば時間管理やスコア管理などはこうした方がよかったかなぁと反省しています。

既知のバグ

一部のアイテムが投げるともとの位置に戻ってしまいます。薄いアイテムや小さいアイテムでよく見られます。原因はよくわかりませんでしたが、点数は加算されてるし、まぁいいか、と放置してあります。

その他

  • ものに応じてmassを設定し弾き飛ばしやすさを調整したかったのですが、アイテム数を優先した結果割愛となりました。
  • 当初は、ものを移動させた距離の総和をとり、これをスコアとする予定でした(その方が大胆に散らかしたくなるはず!)。しかし実現方法が思いつかず断念。

次に参加するときは今回得たノウハウを活かしつつも、まだ触ったことのない機能や要素をどんどん使ってみたいなと思います!

MIT OCW 6.006 Lecture 1: Peak-finder ノート

ocw.mit.edu

Lecture 1で紹介された問題「Peak-finder」に関するノートと考えたことのまとめ。

One-dimensinal version

Problem

Find a peak if it exists.

1 2 3 4 5 6 7 8 9
a b c d e f g h i

a~i: integer
表記: {1}=a, {2}=b, ... , {9}=i
  • Position 2 is a peak if and only if b >= a and b >= c.
  • Position 9 is a peak if and only if i >= h.

Argue:Peakは常に存在するか?

Yes.

  • 素数が1の場合はその要素がPeak
  • 素数が2以上で、Peakではない要素のみからなる配列が存在すると仮定し、その構築を試みる。
    • {1}はPeakではない。よって{1} < {2}
    • {2}も同様。よって{1} < {2} < {3}
    • ...
    • {1} < {2} < ... < {N} (Nは配列の要素数)
    • このとき{N}はPeakになり、仮定に反する。

Solution:Straightforward Algorithm

左から順に要素がPeakであるか確認する。

計算量:O(N)

  • 最悪のケースは単調増加数列。すべての要素を調べることになる。
  • 平均的には N/2 個の要素を調べることになる。

左から順ではなく中央の要素から確認するようにした場合、計算量は依然O(N)だが、調べる要素の個数は最悪でもN/2個、平均でN/4個になる。

Solution: Divide & Conquer

以下の要領で探索範囲の大きさを半分に限定していく。

  • {n/2} < {n/2-1} の場合、 {1} ~ {n/2-1} にPeakがある。
  • {n/2} < {n/2+1} の場合、 {n/2+1} ~ {n} にPeakがある。
  • いずれでもない場合は、{n/2}がPeak

計算量:O(lgN)

  • T(N)を計算量とする。
    • T(N)
    • = T(n/2) + O(1)
    • = T(n/4) + O(1) + O(1)
    • = ...
    • = O(1) + ... + O(1) (lgN times)
    • = O(lgN)

Argue: Is That Algorithm Correct?

  • 探索範囲 {s} ~ {e} が、常に {s} < {s+1} と {e} < {e-1} を満たす場合:
    • 探索範囲に含まれる要素の数が1 or 2のとき、この条件は満たせない。
    • 探索範囲に含まれる要素の数が3のとき、この条件を満たすのは {s} < {s+1} > {s+2 = e} のケースのみなので次のステップで見つかる
    • 探索範囲に含まれる要素の数が3より大きくても、探索範囲を狭めていくといずれ要素数が3になって見つかる

{1} >= {2} or {N} >= {N-1} の場合上記の前提が満たされないが、この場合は最悪のケースでも探索範囲が{1} ~ {N} => {1} ~ {N/2} => ... {1} ~ {2} などとなるのでlgNステップで見つかる。

Two-dimensional version

Problem

Find a 2D-peak if it exists.

_ c _ _
b a d _
_ e _ _
_ _ _ _
N rows, M columns

a~d, _: integer
表記: {1,2}=c, {2,1}=b, {2,2}=a, {2,3}=d, {3,2}=e
  • a is a 2D-peak if a>=b, a>=c, a>=d, a>=e.

Attempt: Extend 1D Divide and Conquer to 2D

    j=M/2
  _ * _ _
i * * * *
  _ * _ _
  _ * _ _
  • 中央の列j=m/2をとり、1D-peakを見つける。(行iに見つけたとする)
  • 行iの中で1D-peakを見つける。これを2D-peakとする。

問題点:2D-peakが行iにあるとは限らない

うまくいかない例:
__ __ 10 __
14 13 12 __
15  9 11 __
16 17 19 20

Attempt:

  • 中央の列j=m/2をとる。
  • 列j内の最大値を見つける。(見つけた場所を(i,j)とする)
  • (i,j) < (i,j-1)なら左側を残す。
  • (i,j) < (i,j+1)なら右側を残す。
  • どちらでもないなら(i,j)は2D-peak。
  • 残り1列になったら、列内の最大値が2D-peak。

計算量:O(NlgM)

  • T(N,M)を計算量とする。
    • T(N,M)
    • = T(N,M/2) + O(N)
    • = T(N,M/4) + O(N) + O(N)
    • = ...
    • = O(N) + ... + O(N) (lgM times)
    • = O(NlgM)

Is That Algorithm Right?

たとえばこういうケース

2 7
3 8
4 7
5 6

列1の最大値は{4,1}=5。{4,1}<{4,2}なので探索範囲を列2に絞るが、この時点で{列1の任意の値} <= {列2の最大値}が確定する。

このように、探索範囲内に残った両端の列の最大値は、隣接する探索範囲外の列の値に対して常に2D-peakの条件を満たす。したがって、最後の1列になるまで探索範囲を限定し、列内で最大値をとればそれは2D-peak。

ちなみに、列内の最大値をとる代わりに列内の1D-peakを使用した場合は上記の性質が失われるので、たとえば以下のケースで2D-peak検出に失敗する。

2 3
3 4
2 5
9 6

2D-peakは{1,4}=9のみだが、{2,4}=6が検出される!

「わかりみSQL」を読んで得た知見と感想

わかりみSQL - カウプラン機関極東支部 https://kauplan.org/books/wakarimisql/ booth.pm

動機

SQLの理解が曖昧だった。 一度包括的に学び、テーブル設計や実行効率などを考える上での足がかりとしたい。

新しく知ったこと

  • サブクエリという概念。withでサブクエリの結果に名前をつけられるが、最適化が外れるので使用の際は注意。
  • テーブル結合 = すべての組み合わせの生成 + 条件による絞り込み。 演算子joinはテーブル結合をwhereから分離する。
    • using: 列名が同じかつ = のとき
    • natural joinは可読性に難があるので避ける
    • 自己結合: 同テーブル内の別の行を結合したりするなど。
  • left outer joinとright outer join: 集合A,Bの共通部分をとる内部結合(inner join)に対し、A\Bをくっつけるイメージ。leftとrightはテーブルの位置関係。cross joinはすべての組み合わせの生成だけする。
  • 複合主キー/外部キー: 複数の主/外部キーを指定
  • 関連
    • 1:多は、join時の各行がそれぞれrootからleafに至る1つのパスに対応する。
    • 多:多は交差テーブルで対処。実質inner joinみたいなもの。
  • index
    • 必要な列だけbtreeで管理し、高速検索(数百倍違う!)を可能にする。
    • カーディナリティが高い列に作成しよう。uniqueな列には勝手に作成されてる。
    • 変更が加わる際はちょっと遅くなるので注意。create index concurrently
    • 式インデックス、部分インデックス、Index Only Scan
  • 計測系
    • timing on: 時間測定モード
    • explain: 実行内容を表示
  • 日付と日時の型(ソフトウェアに依存するので話半分)
    • date, timestamp, interval
    • current_date, current_timestamp, now()
    • date_part, extract, age()
    • date_truncで週初めや月始めを取得。generate_seriesなどと組み合わせて今月の日の全列挙なども
  • case
    • case when P then A else B end
    • case COL when A then A' else B' end
  • 相関サブクエリ
    • サブクエリ内でサブクエリ外で取得した行の値を使用すると、その行の数だけサブクエリが実行される。
    • MySQLはwhere句に書いたサブクエリについては相関サブクエリと同じ動きをする。
  • 数字の連番 generate_series(begin, end)とそれを使った日付の連番 select ('2020-02-02'::date + make_interval(days => n.num))::date from generate_series(0, 7-1) as n(num)
  • window関数
    • over (partition by ... order by ...) または over hoge_window ... window hoge_window as (partition by ... order by ...)
    • partition by: 集約しないでgroup byみたいなもの。パーティションに分ける。指定しない場合全体が1つのパーティション
    • order by: パーティションごとにorder by。ウィンドウフレームの現在行の基準を定める。指定しない場合現在行はパーティション最終行に固定。
    • ウィンドウフレーム: パーティション先頭から現在の行までの範囲
    • row_number()とrank(), lag()とlead(), sum/max/minはウィンドウフレームに注意
  • 集約関数いろいろ。文字列をjoinするやつ、配列やjsonつくるやつ、and/or
  • insert関連
    • returning: insertなどの処理に返り値を設定できる
    • values: 行をベタ書きできる
    • update or insert: returningを使うか on conflict
  • union/union all
  • 再帰クエリ
    • with recursive X as ( + 最初の実行結果Xを取得 + union all + 直前のXを用いて次の実行結果を取得 + ) select * from X
  • トランザクション in postgres

感想

ボリュームはあるが、しっかりとした実践的な理解が得られた。SQL素人の一冊目に最適だったと思う。

Docker上でやる場合は、コンテナ内にサンプルファイルを落とすのがいいんじゃないでしょうか。

$ apt update && apt install curl unzip
$ curl https://kauplan.org/books/wakarimisql/sqlfiles_20190922.zip > tmp.zip
$ unzip tmp.zip

総合問題23.1のpriceの値が書籍とサンプルファイルで異なるので注意。