見出し画像

clusterのワールドで画面全体をブルブル振動させたいときに読む記事

clusterのワールドで画面全体をブルブル振動させたいときに読む記事です。

※注意: VRでは動作しない仕組みを紹介してることにご注意下さい。

はじめに…の前に: 動いてる様子とかワールドとか

下記のようなワールドが作れます、という紹介をします。

そしてワールドはコチラです。このワールドには画面振動以外にも「お試しで作ったおもしろ機能」をいろいろ足していく予定ですが、本noteの時点では画面振動のサンプルだけを置いています。


はじめに

画面振動とは何ぞや、とか、その実装例などについては「桜井政博のゲーム作るには」で説明されています。

この動画から分かるように画面振動は入れられるシチュエーションでは是非入れたいエフェクトですが、clusterで画面振動を導入する方法はそこまで自明ではなく、また技術解説もまだ出回ってなさそうだったため、noteの作成に至っています。

アプローチ: PlayerLocalUIを使ってPostProcessのようなことをする

上述のYouTubeで紹介されている中でも、とくにカメラの揺れではなく「画面全体を揺らす」という実装で画面振動を作ることを考えます。

Unity Engineでゲームを作る場合、これは Post Processing で実装すると自然に作れそうですが、clusterでは自作のPost Processingのエフェクトは使えません。そこで代替手段として以下のようにします。

  • PlayerLocalUIに全画面を覆うRawImageオブジェクトを用意する

  • RawImageのマテリアルとして、UI以外の描画結果をGrabPassで取得するシェーダーを使い、PostProcessingっぽい描画を行う

  • シェーダーのパラメーターが渡したい場合、Colorプロパティ経由で渡してしまう

画面振動シェーダーの実装

最初はプレイヤーによるインタラクト等は考慮せず、「常に画面がブルブルしているワールド」を作り、インスペクター上で揺れの強さが調整できるようにします。

こんな感じのシェーダーを新規に作成します。

Shader "UI/ScreenVibrationEffect"
{
    Properties
    {
        // 揺れのスピード(Hz)
        _ShakeSpeed ("Shake Speed", float) = 100.0
        // 縦揺れの強さ (画面サイズに対する比率として指定
        _ShakeStrength ("Shake Strength", float) = 0.01
    }
    SubShader
    {
        Tags { "Queue"="Transparent" }
        GrabPass { "_ScreenVibrateTexture" }
        Cull Off
        Lighting Off
        ZWrite Off        

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            float _ShakeSpeed;
            float _ShakeStrength;
            sampler2D _ScreenVibrateTexture;

            struct appdata_t
            {
                float2 uv : TEXCOORD0;
                float4 vertex : POSITION;
                // このcolorにはImageやRawImageコンポーネントのcolorプロパティで指定した色が入る
                float4 color : COLOR;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
                float2 uvGrab : TEXCOORD1;
                float4 color : COLOR;
            };

            v2f vert(appdata_t v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                o.color = v.color;
                o.uvGrab = ComputeGrabScreenPos(o.pos).xy / o.pos.w;
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                // 時間に応じた縦方向の揺れを加算する。ポイントは2つ
                // - ピクセル数ではなく画面のサイズ比で揺らすため、 _ScreenParams.y とかTexelSizeを見ない
                // - 揺れ強度の比率をImageやRawImageの colorのr成分として受け取る
                float2 uv = i.uvGrab + float2(
                    0, sin(_Time.y * _ShakeSpeed * 3.1415 * 2) * _ShakeStrength * i.color.r
                    );

                // 画面外の部分は黒くする: 縦揺れなのでyだけケアすればOK
                if (uv.y < 0.0 || uv.y > 1.0)
                {
                    return fixed4(0, 0, 0, 1);
                }

                fixed4 col = tex2D(_ScreenVibrateTexture, uv);
                return col;
            }
            ENDCG
        }
    }
}

上記のシェーダー上にもコメントを入れている通り、いくつかポイントがあります。

  • GrabPassで得られたテクスチャをサンプリングして、uvの調整で画面全体を揺らす

  • ImageやRawImageなどで指定できるColorプロパティの値を経由して、画面振動の強さが調整できるようにする

このシェーダーを割り当てたマテリアルを作成します。

そしてPlayerLocalUIにCanvasを覆うようなRawImageを配置して、マテリアルをアタッチします。

これでEditor上でプレビュー実行すると、つねに画面が振動することが確認できます。下記のツイートではワールドをアップロードしていますが、この段階ではEditor実行だけでOKです。

また、実行したままEditor上でRawImageのColorを白から黒へと変化させていくことで、画面振動がだんだん弱くなっていくことも確認できます。

インタラクト操作に対して振動させる

先ほど作った画面振動するエフェクトを活用して、「インタラクトすると短時間だけ画面振動するキューブ」を作ります。

まず、プロジェクト上でCluster Scriptとして2つのスクリプトを記述します。

ScreenVibrationEffect_Item.js

$.onInteract((player) => {
  $.setPlayerScript(player);
  player.send("Vibrate", true);
});

ScreenVibrationEffect_Player.js

const VibrateDuration = 1.0;

const imageObj = _.playerLocalObject("ImageObj");
const image = imageObj.getUnityComponent("RawImage");

let isVibrating = false;
let vibrateTime = 0;
let colorValue = new Color(0, 0, 0, 1);

_.onReceive((messageType, arg, sender) => { 
  // NOTE: この実装はVRだとうまくワークしないので、VRでは無効にしておく
  if (_.isVr || messageType !== "Vibrate") return;

  isVibrating = true;
  vibrateTime = 0;

  // Receiveした瞬間の揺れを最大値にしておく
  imageObj.setEnabled(true);
  colorValue.r = 1.0;
  image.unityProp.color = colorValue;
});

_.onFrame((deltaTime) => {
  if (!isVibrating) return;

  vibrateTime += deltaTime;
  if (vibrateTime > VibrateDuration) {
    isVibrating = false;
    imageObj.setEnabled(false);
    return;
  }

  const shakeStrengthRate = 1 - cubicOut(clamp01(vibrateTime / VibrateDuration));
  colorValue.r = shakeStrengthRate;
  image.unityProp.color = colorValue;
});

// [0, 1] の範囲で二次曲線に沿って補間されるような値を作る
const cubicOut = (v) => (2 - v) * v;

// 値を [0, 1] の範囲に丸める
const clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;

続いて、ワールド上に適当なキューブを配置して、コチラの画像のようにコンポーネントをセットアップします。

  • Scriptable Item: 短いほうのスクリプト(ScreenVibrate_Item.js)を指定

  • Player Script: 長いほうのスクリプト(ScreenVibrate_Player.js)を指定

  • Player Local Object Reference List: 先ほど作った、画面振動をさせるRawImageがついたオブジェクトを ImageObj というidをつけて指定

このセットアップののち、RawImageのオブジェクトはデフォルトで非アクティブにしておいてワールドをアップロードすると、「インタラクトで画面振動するキューブ」の完成です。

これで振動する部分は導入できました!

冒頭のワールドでは、この状態に対して ScreenVibration_Item.js のスクリプトを拡張したり、Particle Systemを追加したりして演出を盛っています。

  • Cubeの動き: 一瞬で落ちたあと、しばらくすると戻る

  • Cubeの落下後のパーティクル表示: Particle Systemのあるオブジェクトを一時的に SubNode.setEnabled でアクティブにする

  • SEの再生: パーティクルを有効化するとき、あわせて再生する

これらの拡張は画面振動とは特に関係なく実装できるので、個別に調べて盛りたいだけ盛っていけばOK…という感じになります。

基本説明は以上です!


応用例 (画面振動)

上記までで作ったサンプルでは「インタラクトしたプレイヤーの画面が揺れ、他のプレイヤーの画面は揺れない」という制限があります。

この制限は実はそこまで本質的な制限ではありません。下記の2つを行うことで、ワールド内の複数プレイヤーの画面も振動させられます。

  • ワールドに入った人に $.setPlayerScript でPlayer Scriptを適用しておく

  • 画面を揺らしたいプレイヤーに対して PlayerHandle.send で"Vibrate"を送りつける 

とくに「動く電車や飛行機の中」というワールドであったり、「ワールドに隕石が落ちてきた!」という演出のあるワールドなどで、上記のようにして全プレイヤーの画面を振動させることにチャレンジすると良いかもしれません。

応用例 (画面振動以外)

今回は「Post Processingっぽい効果をねじ込む」というアプローチを通じて画面を振動させています。

そのため、シェーダーの frag 関数の部分を改変して常時適用すると、じつは振動に限らず自作のPost Processingっぽい処理を色々と当て込めます。

下記は色をいい感じに減色するシェーダーで ファミレスを享受せよ の彩色をオマージュしてみた例です。

この方法ではワールドだけでなくアバターにも彩色が効くのが良いですね。

で。

これは一見とてもパワフルな手法ですが、欠点としてカメラモードを起動するとPlayerLocalUIは非表示になるため、撮影には向いていません。また、メニューを開いた場合もPlayerLocalUIが非表示になるので、メニューの裏側でエフェクトが切れてるのがバレる、みたいな話もあります。さらに、そもそもVRだと全視界が覆えないのでエフェクトがハンパになってしまうという問題もあります。

このへんの問題を回避しきるアイデアも考えてはいますが、画面振動とは全く別のトピックになってくるため、機会があれば別で紹介します。


Known Issue: 端末によってはPlayerLocalUIのシェーダーがうまく動かない?

最後に、これは詳しく調べきれていないのですが既知の問題の補足をします。

実は今回作った画面振動ですが、手元で試したところ

  • Windows 11: 問題なく動く

  • iPad Pro (2th Gen): 問題なく動く

  • Android (XPeria 1 III): 動かない or 画面の一部だけが振動する

というデバイス依存性があるのを確認しています。

特に「画面の一部」という所が良くも悪くも面白くて、メニューやアバター選択画面を一度でも開いた後だと、メニュー画面のうちブラーエフェクトが効いていない領域でだけエフェクトが効くようになります。

ちょっと見づらいですが、揺れた/揺れないの境界線が出てるスクショがこんな感じで、画面の左側だけが揺れています。

恐らくclusterのシステム上のUIと競合してそうではあるんですが、原因は特定できていません……。

まあそもそもGrabPassのあるシェーダー自体ちょっと重たい処理ではあるので、「画面振動はPC限定の処理です!」と割り切る対策もアリです。こうするには、ScreenVibrationEffect_Player.jsのうち、 _.onReceive のコールバック部分を少しだけ書き換えて、エフェクト用の画像がアクティブになるのを避ければOKです。

_.onReceive((messageType, arg, sender) => { 
  // ↓ここの行に || _.isMobile を追加して、モバイルでも振動は無効にする
  if (_.isVr || _.isMobile || messageType !== "Vibrate") return;

  ...
});


以上です。

noteとしては実装寄りの話題でしたが、ともかく画面振動があると嬉しい場面はclusterワールドの随所にあると思うので、「自分のワールドと相性が良いかも」と思ったらぜひ導入してみてください!


いいなと思ったら応援しよう!