- 投稿日:2019-07-14T22:17:47+09:00
AzurePlayFabのとても便利なマッチメイキング(マッチング)機能を使ってみた
まえがき
マッチメイキングの処理はオンラインゲームにはかかせないですよね。
でもマッチメイキングを実装しようとすると、やりたいことはプレイヤーに適切なルームやバトルのIDを渡したいだけなんですが、様々なルール(プレイヤーのレベルやレートの考慮、1vs1 or チームバトル or バトルロイヤル)や排他制御を考える必要があって大変です。
そんなマッチメイキングを PlayFab を使えば簡単に実装できると聞いたので試してみました。
※今回は複雑なルールは取り上げません。
※マッチメイキングは Public Preview の機能です。作ってみたもの
PlayFab を使ったマッチングの流れ
- 事前に GameManager でマッチング処理用のキューを作り、バトルの人数やマッチングする条件を定義します。
- Player が上記のキューに MatchmakingTicket というチケットを投げます。
- キューにバトルの人数分のチケットが貯まるとマッチングされます。
- Player が投げたチケットをポーリングしておけば MatchID が取得できるので、同じMatchID の Player を集めてバトルを開始したりします。
使用する PlayFab の API
- CreateMatchmakingTicket - チケットを作ってキューに投げるときに使います
- GetMatchmakingTicket - チケットのステータスををポーリングするときに使います
- CancelMatchmakingTicket - マッチングをキャンセルするときに使います
1. 事前準備(GameManager でマッチング処理用のキューを作っておく)
GameManager へログインし、マルチプレイヤー > マッチメイキング > 新しいキューをクリックします。
キューの構成を変更してキューを作成します。
必須項目はキュー名
と対戦人数
です。
今回は 1vs1 のマッチングを作りますので、それっぽいキュー名を指定し、対戦人数は必ず2人なので 2to2 に設定しておきます。
キューができました。
GameManager での事前準備はここまでです。
2. 最低限のコードでシンプルなマッチングを動かしてみる
Unity でこんなコードを書いてみました。
これを動かすと先に張ったサンプルのようにシンプルな1vs1のマッチングを行うことができます。using PlayFab; using PlayFab.ClientModels; using PlayFab.MultiplayerModels; using System.Collections; using UnityEngine; using UnityEngine.UI; public class SampleSceneController : MonoBehaviour { // 処理中のメッセージは雑に全部これに表示します。 [SerializeField] Text textBox; public void Start() { textBox.text = "ログイン中...\n"; // PlayFabにいつも通りログインします。 var request = new LoginWithCustomIDRequest { CustomId = "MyCustomId", CreateAccount = true }; PlayFabClientAPI.LoginWithCustomID(request, OnLoginSuccess, OnFailure); void OnLoginSuccess(LoginResult result) { textBox.text += "ログインしました!\n\n"; // ログインできたので続けてマッチングの処理を呼びます。 Matchmaking(); } } private void Matchmaking() { textBox.text += "マッチメイキングチケットをキューに積みます...\n"; // プレイヤーの情報を作ります。 var matchmakingPlayer = new MatchmakingPlayer { // Entityは下記のコードで決め打ちで大丈夫です。 Entity = new PlayFab.MultiplayerModels.EntityKey { Id = PlayFabSettings.staticPlayer.EntityId, Type = PlayFabSettings.staticPlayer.EntityType } }; var request = new CreateMatchmakingTicketRequest { // 先程作っておいたプレイヤー情報です。 Creator = matchmakingPlayer, // マッチングできるまで待機する秒数を指定します。最大600秒です。 GiveUpAfterSeconds = 30, // GameManagerで作ったキューの名前を指定します。 QueueName = "1vs1Battle" }; PlayFabMultiplayerAPI.CreateMatchmakingTicket(request, OnCreateMatchmakingTicketSuccess, OnFailure); void OnCreateMatchmakingTicketSuccess(CreateMatchmakingTicketResult result) { textBox.text += "マッチメイキングチケットをキューに積みました!\n\n"; // キューに積んだチケットの状態をマッチングするかタイムアウトするまでポーリングします。 var getMatchmakingTicketRequest = new GetMatchmakingTicketRequest { TicketId = result.TicketId, QueueName = request.QueueName }; StartCoroutine(Polling(getMatchmakingTicketRequest)); } } IEnumerator Polling(GetMatchmakingTicketRequest request) { // ポーリングは1分間に10回まで許可されているので、6秒間隔で実行するのがおすすめです。 var seconds = 6f; var MatchedOrCanceled = false; while (true) { if (MatchedOrCanceled) { yield break; } PlayFabMultiplayerAPI.GetMatchmakingTicket(request, OnGetMatchmakingTicketSuccess, OnFailure); yield return new WaitForSeconds(seconds); } void OnGetMatchmakingTicketSuccess(GetMatchmakingTicketResult result) { switch (result.Status) { case "Matched": MatchedOrCanceled = true; textBox.text += $"対戦相手が見つかりました!\n\nMatchIDは {result.MatchId} です!"; return; case "Canceled": MatchedOrCanceled = true; textBox.text += "対戦相手が見つからないのでキャンセルしました..."; return; default: textBox.text += "対戦相手が見つかるまで待機します...\n"; return; } } } void OnFailure(PlayFabError error) { Debug.Log($"{error.ErrorMessage}"); } }3. プレイヤーのレートやレベル差を考慮したマッチングを動かしてみる
実際のゲームではレートやレベル差によって、初心者と上級者のマッチングを分けたりしますよね。
PlayFab ではどのように制御すればよいのか試してみました。
3.1. キューにルールを追加する
先程キューを作ったときは最低限の内容しか定義しませんでした。
今回は以下のようなルールを追加してみます。このルールを追加することで、Rate の差が 101 以上のプレイヤー同士はマッチングしなくなります。
3.2. プレイヤーの Rate 情報をチケットに含める
先程のコードではチケット内の MatchmakingPlayer には Entity 情報しか含んでいませんでした。
今回は Rate の情報をもたせるために Attributes を使用します。// マッチングさせるプレイヤーの情報を作ります。 var matchmakingPlayer = new MatchmakingPlayer { // Entityは下記のコードで決め打ちで大丈夫です。 Entity = new PlayFab.MultiplayerModels.EntityKey { Id = PlayFabSettings.staticPlayer.EntityId, Type = PlayFabSettings.staticPlayer.EntityType }, // これ以下を追記 Attributes = new MatchmakingPlayerAttributes { // このプレイヤーは Rate 900~1100 のプレイヤーとしかマッチングしない DataObject = new { Rate = 1000 } } };これだけでレートやレベル差を考慮したマッチングを実装することができました。
これは楽で良いですね。あとがき
今回はマッチメイキングの基本の部分を触ってみました。
まだ公式ドキュメントを読み切れていないのですが、PlayFab のマッチメイキング機能はもっと複雑なマッチングルールやチームバトルにも対応しているということですので、引き続き触ってみようと思います。
- 投稿日:2019-07-14T21:42:32+09:00
UnityのEditor拡張から値を更新したあとUnity再起動すると値がもとに戻る問題
環境
Unity 2018.3.12f1
はじめに
Inspector上で値を変更した変数がUnityを再起動したときにもとに戻ってしまう原因はおもに2つあります。
- 変数がSerializeされていないもしくはSerializeできない型を使っている
- Sceneが値の変更を検知していない(本題)
変数がSerializeされていないもしくはSerializeできない型を使っている
これに関してはUnityのドキュメントを参照すると詳しく書いてありますが、Serialize属性をつけるかシリアライズ可能な型を使うことで回避できます。
https://docs.unity3d.com/ja/current/Manual/script-Serialization.html
- Serializable 属性をもつカスタムの非抽象クラスと非ジェネリッククラス
- Serializable 属性をもつカスタム構造体
- UnityEngine.Object から派生するオブジェクトへの参照
- プリミティブなデータ型 (int、float、double、bool、string など)
- Enum 型
- 特定の Unity ビルトイン型: Vector2、Vector3、Vector4、Rect、Quaternion、Matrix4x4、Color、Color32、LayerMask、AnimationCurve、Gradient、RectOffset、GUIStyle
Sceneが値の変更を検知していない(本題)
ここからが本題ですがまず問題のコードを見てください。
問題のコード
Testclass.csusing UnityEngine; using UnityEditor; public class Testclass : MonoBehaviour { [HideInInspector] public int hoge = 0; } [CustomEditor(typeof(Testclass))] public class TestclassInspector : Editor { public override void OnInspectorGUI() { var obj = target as Testclass; base.OnInspectorGUI(); obj.hoge = EditorGUILayout.IntField(obj.hoge); } }Testclassはint型の変数hogeを持っています。customEditorから値を操作するためにpublicにした上で[HideInInspector]でInspector上で非表示にしていますが、プリミティブ型なのでserializeされているはず。
適当なオブジェクトにアタッチして
値を変更。Ctrl+Sで保存
Unityを再起動。値は変更前に戻ってしまっています。
原因
このようなことが起きる理由はEditorGUILayout.〇〇Fieldで変更したときにsceneに変更された情報が反映されておらず、保存できてないからです。
反映されたかどうかはHierarchy上のシーン名の隣に*がつくことでわかります。
例えば、値を9999にしたあとTransformのPositionのxをちょっとだけ動かすことで*がつきました。
保存してUnityを再起動すると値も保存されてたことがわかります。
なぜこういうことがおきるのか
EditorからもとのコンポーネントにアクセスするにはSerializedObjectを通す必要があり、EditorGUILayout.〇〇Fieldで取得した値を再代入する方法それを介さないはSceneに変更が通知されないということでしょう。
ただ、SerializedPropertyを使用したアクセスの方法は、クラスや配列を扱うのが大変なのでできればEditorGUILayout.〇〇Fieldで扱いたいわけです。解決法
Testclass.csusing UnityEngine; using UnityEditor; using UnityEngine.SceneManagement; using UnityEditor.SceneManagement; public class Testclass : MonoBehaviour { [HideInInspector] public int hoge = 0; } [CustomEditor(typeof(Testclass))] public class TestclassInspector : Editor { public override void OnInspectorGUI() { var obj = target as Testclass; base.OnInspectorGUI(); EditorGUI.BeginChangeCheck(); obj.hoge = EditorGUILayout.IntField(obj.hoge); if (EditorGUI.EndChangeCheck()) { var scene = SceneManager.GetActiveScene(); EditorSceneManager.MarkSceneDirty(scene); } } }解説
まず必要な名前空間を呼び出し
using UnityEngine.SceneManagement; using UnityEditor.SceneManagement;値を変更する部分をEditorGUI.BeginChangeCheck();とEditorGUI.EndChangeCheck()ではさみます。
変更時にEndChangeCheck()がtrueを返すので、そのたびにEditorScenemanager.MarkSceneDiryを使ってシーンが汚れたことを手動で書き込みます。EditorGUI.BeginChangeCheck(); obj.hoge = EditorGUILayout.IntField(obj.hoge); if (EditorGUI.EndChangeCheck()) { var scene = SceneManager.GetActiveScene(); EditorSceneManager.MarkSceneDirty(scene); }結論
EditorGUILayout.〇〇Fieldを使う場合は必ずMarkSceneDirtyしましょう。
- 投稿日:2019-07-14T21:36:06+09:00
Babylon.js で Oculus Quest で気軽に入れる VRクラブを作る (2)
はじめに
本記事の内容は の続きになります。
Babylon.js で Oculus Quest で気軽に入れる VRクラブを作る (1)前回は Oculus Quest で動き回れる VR クラブを作成しました
今回はもっと VR 的な演出を盛り込んだり、実際に音楽を再生したりして、
実際に盛り上がれる感じの空間として作り込んでいきたいと思いますまた、今回は音楽再生や波形解析のために Tone.js を使用します
動作環境
- ホスティングサーバ
- Node.js v11.14.0
- Express 4.17.1
- フロントエンド
- Babylon.js 4.0.3
- Tone.js 13.8.18
音楽の再生/停止を Babylon.js で制御出来るようにする
VR クラブ内で再生する音楽を 3D空間上から制御できるようにします
1. Tone.js を使用して音楽を再生してみる
まずは VR クラブにマッチしそうな音楽を選定します
僕は Sonic Pi で作った音源を BGM にすることにしました
ファイルフォーマットは mp3 にしました。
audio
フォルダを作成してmusic.mp3
を配置していますbabylon-js/public. ├── audio # 新たに audio フォルダを作成し music.mp3 ファイルを配置 │ └── music.mp3 ├── images ├── index.html ├── javascripts ├── shaders └── stylesheetsそれでは 前回作成した index.html に Tone.js を CDN 経由で取り込み、
実際に用意した mp3 ファイルを再生してみましょうbabylon-js/public/index.html<!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html" charset="utf-8" /> <title>Babylon Club</title> <script src="https://cdn.babylonjs.com/babylon.js"></script> <!-- Tone.js を CDN 経由で取り込む --> <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/13.8.18/Tone.min.js"></script> <style> html, body { overflow: hidden; width: 100%; height: 100%; margin: 0; padding: 0; } #renderCanvas { width: 100%; height: 100%; touch-action: none; } </style> </head> <body> <canvas id="renderCanvas"></canvas> <script> const canvas = document.getElementById('renderCanvas'); const engine = new BABYLON.Engine(canvas, true); // Tone.js の Player を使用して、用意した mp3 の再生を準備する const player = new Tone.Player({ url: '/audio/music.mp3', loop: true, }).toMaster(); // mp3 ファイルのロード完了後、自動で mp3 の再生を開始する Tone.Buffer.on('load', function () { player.start(); }); const createScene = () => { const scene = new BABYLON.Scene(engine); const camera = new BABYLON.FreeCamera('MainCamera', new BABYLON.Vector3(0, 0, 0), scene); camera.setTarget(BABYLON.Vector3.Zero()); camera.attachControl(canvas, true); const box = BABYLON.MeshBuilder.CreateBox('Box', { size: 20, sideOrientation: BABYLON.Mesh.BACKSIDE }, scene); box.position = BABYLON.Vector3.Zero(); const shaderMaterial = new BABYLON.ShaderMaterial('floor', scene, './shaders/floor'); box.material = shaderMaterial; const light = new BABYLON.HemisphericLight('AmbientLight', new BABYLON.Vector3(0, 0, 0), scene); return scene; } const scene = createScene(); const vrHelper = scene.createDefaultVRExperience(); vrHelper.enableInteractions(); var _rotationAngle = 0; vrHelper.onControllerMeshLoaded.add((webVRController) => { var isHorizontalRotate = false; const angle = Math.PI / 4; webVRController.onPadValuesChangedObservable.add((stateObject) => { if (webVRController.hand === 'left') { var matrix = BABYLON.Matrix.RotationAxis(BABYLON.Axis.Y, angle * _rotationAngle); var move = new BABYLON.Vector3(stateObject.x, 0, -stateObject.y); var addPos = BABYLON.Vector3.TransformCoordinates(move, matrix); vrHelper.position = vrHelper.position.add(addPos) } else { if (isHorizontalRotate && Math.abs(stateObject.x) > 0.8) { isHorizontalRotate = false; stateObject.x > 0 ? _rotationAngle++ : _rotationAngle--; var target = BABYLON.Quaternion.FromRotationMatrix(BABYLON.Matrix.RotationY(angle * _rotationAngle)); vrHelper.currentVRCamera.rotationQuaternion = target } else if (Math.abs(stateObject.x) < 0.8) { isHorizontalRotate = true } } }); }); const box = scene.getMeshByName('Box') var time = 0.0; engine.runRenderLoop(() => { box.material.setFloat('time', time); scene.render(); time += 0.01; }); window.addEventListener('resize', function () { engine.resize(); }); </script> </body> </html>index.html を更新して、
npm run start
を実行後http://localhost:3000
にアクセスした際に音楽が流れていれば成功です2. Babylon.js で 3D空間上に再生/停止を制御するためのボタンを配置する
次に音楽の再生に関わる操作を 3D 空間上で行えるように、再生/停止ボタンを配置することにします
Babylon.js には 3D 空間上で GUI を作成するための ライブラリ が用意されているため、
それを活用することで簡単に 3D空間上にクリック可能なボタンを配置出来ます。それでは早速 3D空間上にボタンを配置し、
クリックすることで音楽の再生/停止が切り替えられるようにしますbabylon-js/public/index.html<!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html" charset="utf-8" /> <title>Babylon Club</title> <script src="https://cdn.babylonjs.com/babylon.js"></script> <!-- Babylon.js の 3D GUI プラグインを CDN 経由で取り込む --> <script src="https://cdn.babylonjs.com/gui/babylon.gui.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/13.8.18/Tone.min.js"></script> <style> html, body { overflow: hidden; width: 100%; height: 100%; margin: 0; padding: 0; } #renderCanvas { width: 100%; height: 100%; touch-action: none; } </style> </head> <body> <canvas id="renderCanvas"></canvas> <script> const canvas = document.getElementById('renderCanvas'); const engine = new BABYLON.Engine(canvas, true); // Tone.js の Player を使用して、用意した mp3 の再生を準備する const player = new Tone.Player({ url: '/audio/music.mp3', loop: true, }).toMaster(); // 画面上に表示するボタンをクリックした際のコールバック関数 // 既に Player が mp3 を読み込み済みの時に、 // 音楽が既に再生されていれば停止、停止されていたら再生を行う const onPlayPauseButtonClicked = () => { if (player.loaded) { if (player.state == 'started') { player.stop() } else { player.start() } } } const createScene = () => { const scene = new BABYLON.Scene(engine); const camera = new BABYLON.FreeCamera('MainCamera', new BABYLON.Vector3(0, 0, 0), scene); camera.setTarget(BABYLON.Vector3.Zero()); camera.attachControl(canvas, true); const box = BABYLON.MeshBuilder.CreateBox('Box', { size: 20, sideOrientation: BABYLON.Mesh.BACKSIDE }, scene); box.position = BABYLON.Vector3.Zero(); const shaderMaterial = new BABYLON.ShaderMaterial('floor', scene, './shaders/floor'); box.material = shaderMaterial; const light = new BABYLON.HemisphericLight('AmbientLight', new BABYLON.Vector3(0, 0, 0), scene); // Babylon.js の GUI ライブラリを使用して、ユーザがクリック可能なボタンを作成する // ボタンの名前は PlayPauseButton としている const manager = new BABYLON.GUI.GUI3DManager(scene); const playPauseButton = new BABYLON.GUI.HolographicButton('PlayPauseButton'); manager.addControl(playPauseButton); // playPauseButton ボタンのポジションを y: -0.2m, z: -5m に配置する playPauseButton.position.y = -0.2; playPauseButton.position.z = -5; // Y軸座標で 180度回転させておく (後に設定するテキストが正面から見られるようにするため) playPauseButton.mesh.rotate(BABYLON.Axis.Y, Math.PI, BABYLON.Space.WORLD); // playPauseButton ボタンのテキストに Play/Pause というテキストを設定する playPauseButton.text = "Play/Pause"; playPauseButton.onPointerUpObservable.add(onPlayPauseButtonClicked); return scene; } const scene = createScene(); const vrHelper = scene.createDefaultVRExperience(); vrHelper.enableInteractions(); var _rotationAngle = 0; vrHelper.onControllerMeshLoaded.add((webVRController) => { var isHorizontalRotate = false; const angle = Math.PI / 4; webVRController.onPadValuesChangedObservable.add((stateObject) => { if (webVRController.hand === 'left') { var matrix = BABYLON.Matrix.RotationAxis(BABYLON.Axis.Y, angle * _rotationAngle); var move = new BABYLON.Vector3(stateObject.x, 0, -stateObject.y); var addPos = BABYLON.Vector3.TransformCoordinates(move, matrix); vrHelper.position = vrHelper.position.add(addPos) } else { if (isHorizontalRotate && Math.abs(stateObject.x) > 0.8) { isHorizontalRotate = false; stateObject.x > 0 ? _rotationAngle++ : _rotationAngle--; var target = BABYLON.Quaternion.FromRotationMatrix(BABYLON.Matrix.RotationY(angle * _rotationAngle)); vrHelper.currentVRCamera.rotationQuaternion = target } else if (Math.abs(stateObject.x) < 0.8) { isHorizontalRotate = true } } }); }); const box = scene.getMeshByName('Box') var time = 0.0; engine.runRenderLoop(() => { box.material.setFloat('time', time); scene.render(); time += 0.01; }); window.addEventListener('resize', function () { engine.resize(); }); </script> </body> </html>index.html を書き換えたら、再び
http://localhost:3000
にアクセスしてみます画面中央に
Play/Pause
というテキストが設定されたボタンが出現し、
クリックすることで音楽の再生/停止が切り替われば成功です音楽の波形解析データを利用してクラブっぽい演出を VR空間に取り入れる
Tone.js では再生中の音源の波形解析をしてデータを取得することが可能です。
波形解析したデータを元に Babylon.js 内の世界に変化をもたらすことで、
より音楽にマッチしたカッコいい空間を演出することが可能になります1. Tone.js を使って再生中の音楽の波形データを取得する
Tone.js にはデフォで波形解析を行うための各種クラスが用意されているため、
簡単に波形解析を行うことが可能です実際に再生している mp3 ファイルの波形解析してデータを取得してみます
babylon-js/public/index.html<!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html" charset="utf-8" /> <title>Babylon Club</title> <script src="https://cdn.babylonjs.com/babylon.js"></script> <!-- Babylon.js の 3D GUI プラグインを CDN 経由で取り込む --> <script src="https://cdn.babylonjs.com/gui/babylon.gui.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/13.8.18/Tone.min.js"></script> <style> html, body { overflow: hidden; width: 100%; height: 100%; margin: 0; padding: 0; } #renderCanvas { width: 100%; height: 100%; touch-action: none; } </style> </head> <body> <canvas id="renderCanvas"></canvas> <script> const canvas = document.getElementById('renderCanvas'); const engine = new BABYLON.Engine(canvas, true); // 音楽の入力信号を RMS で取得出来るようにする const meter = new Tone.Meter('level'); // 音楽の波形をフーリエ変換して音の高さごとのスペクトラムを取得する // 音の高低の強さが getValue 関数を使用すると配列として取得出来るイメージ // 引数の 64 は分解能 (高低の分割数) を指す const fft = new Tone.Analyser('fft', 64); // ↓ のような音楽の波形を取得する // https://info.shimamura.co.jp/digital/knowledge/2014/03/19260/2 // 引数の 256 は分割数を (波形の分割数) を指す const waveform = new Tone.Analyser('waveform', 256); // 1秒ごとに再生中の音楽の波形データをログで出力する setInterval(() => { if (player.state == 'started') console.log(meter.getValue(), fft.getValue(), waveform.getValue()); }, 1 * 1000) // 1s const player = new Tone.Player({ url: '/audio/music.mp3', loop: true, }).connect(meter).fan(fft, waveform).toMaster(); const onPlayPauseButtonClicked = () => { if (player.loaded) { if (player.state == 'started') { player.stop() } else { player.start() } } } const createScene = () => { const scene = new BABYLON.Scene(engine); const camera = new BABYLON.FreeCamera('MainCamera', new BABYLON.Vector3(0, 0, 0), scene); camera.setTarget(BABYLON.Vector3.Zero()); camera.attachControl(canvas, true); const box = BABYLON.MeshBuilder.CreateBox('Box', { size: 20, sideOrientation: BABYLON.Mesh.BACKSIDE }, scene); box.position = BABYLON.Vector3.Zero(); const shaderMaterial = new BABYLON.ShaderMaterial('floor', scene, './shaders/floor'); box.material = shaderMaterial; const light = new BABYLON.HemisphericLight('AmbientLight', new BABYLON.Vector3(0, 0, 0), scene); const manager = new BABYLON.GUI.GUI3DManager(scene); const playPauseButton = new BABYLON.GUI.HolographicButton('playPauseButton'); manager.addControl(playPauseButton); playPauseButton.position.y = -0.2; playPauseButton.position.z = -5; playPauseButton.mesh.rotate(BABYLON.Axis.Y, Math.PI, BABYLON.Space.WORLD); playPauseButton.text = "Play/Pause"; playPauseButton.onPointerUpObservable.add(onPlayPauseButtonClicked); return scene; } const scene = createScene(); const vrHelper = scene.createDefaultVRExperience(); vrHelper.enableInteractions(); var _rotationAngle = 0; vrHelper.onControllerMeshLoaded.add((webVRController) => { var isHorizontalRotate = false; const angle = Math.PI / 4; webVRController.onPadValuesChangedObservable.add((stateObject) => { if (webVRController.hand === 'left') { var matrix = BABYLON.Matrix.RotationAxis(BABYLON.Axis.Y, angle * _rotationAngle); var move = new BABYLON.Vector3(stateObject.x, 0, -stateObject.y); var addPos = BABYLON.Vector3.TransformCoordinates(move, matrix); vrHelper.position = vrHelper.position.add(addPos) } else { if (isHorizontalRotate && Math.abs(stateObject.x) > 0.8) { isHorizontalRotate = false; stateObject.x > 0 ? _rotationAngle++ : _rotationAngle--; var target = BABYLON.Quaternion.FromRotationMatrix(BABYLON.Matrix.RotationY(angle * _rotationAngle)); vrHelper.currentVRCamera.rotationQuaternion = target } else if (Math.abs(stateObject.x) < 0.8) { isHorizontalRotate = true } } }); }); const box = scene.getMeshByName('Box') var time = 0.0; engine.runRenderLoop(() => { box.material.setFloat('time', time); scene.render(); time += 0.01; }); window.addEventListener('resize', function () { engine.resize(); }); </script> </body> </html>index.html を書き換えたら、
http://localhost:3000
にアクセスします。
console.log
で波形データを出力しているため、開発者を開き出力について確認出来るようにします
謎の小数が大量に表示されていれば成功です
これらの値は音楽の再生状況に応じて刻々と変化していきます解析データを元にオブジェクトやシェーダーの値を変化させることで、
音楽にマッチした効果を VR 空間上に取り入れる事が可能となります2. Tone.js で波形解析したデータを元に VR空間が盛り上がる仕掛けを作る
波形解析は出来たので、あとはフィーリングで VR クラブの中が盛り上がりそうな仕掛けを盛り込んでいきましょう
1. 音の強さに応じて立方体を躍動させる
まずは、音の強さに応じて立方体を躍動させてみたいと思います
先程のindex.html
内にあったTone.Meter
を使用して実現しますbabylon-js/public/index.html<!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html" charset="utf-8" /> <title>Babylon Club</title> <script src="https://cdn.babylonjs.com/babylon.js"></script> <script src="https://cdn.babylonjs.com/gui/babylon.gui.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/13.8.18/Tone.min.js"></script> <style> html, body { overflow: hidden; width: 100%; height: 100%; margin: 0; padding: 0; } #renderCanvas { width: 100%; height: 100%; touch-action: none; } </style> </head> <body> <canvas id="renderCanvas"></canvas> <script> const canvas = document.getElementById('renderCanvas'); const engine = new BABYLON.Engine(canvas, true); // 音楽の入力信号を RMS で取得出来るようにする const meter = new Tone.Meter('level'); // 音楽の波形をフーリエ変換して音の高さごとのスペクトラムを取得する // 音の高低の強さが getValue 関数を使用すると配列として取得出来るイメージ // 引数の 64 は分解能 (高低の分割数) を指す const fft = new Tone.Analyser('fft', 64); // ↓ のような音楽の波形を取得する // https://info.shimamura.co.jp/digital/knowledge/2014/03/19260/2 // 引数の 256 は分割数を (波形の分割数) を指す const waveform = new Tone.Analyser('waveform', 256); const player = new Tone.Player({ url: '/audio/music.mp3', loop: true, }).connect(meter).fan(fft, waveform).toMaster(); const onPlayPauseButtonClicked = () => { if (player.loaded) { if (player.state == 'started') { player.stop() } else { player.start() } } } const createScene = () => { const scene = new BABYLON.Scene(engine); const camera = new BABYLON.FreeCamera('MainCamera', new BABYLON.Vector3(0, 0, 0), scene); camera.setTarget(BABYLON.Vector3.Zero()); camera.attachControl(canvas, true); const box = BABYLON.MeshBuilder.CreateBox('Box', { size: 20, sideOrientation: BABYLON.Mesh.BACKSIDE }, scene); box.position = BABYLON.Vector3.Zero(); const shaderMaterial = new BABYLON.ShaderMaterial('Floor', scene, './shaders/floor'); box.material = shaderMaterial; const light = new BABYLON.HemisphericLight('AmbientLight', new BABYLON.Vector3(0, 0, 0), scene); const manager = new BABYLON.GUI.GUI3DManager(scene); const playPauseButton = new BABYLON.GUI.HolographicButton('PlayPauseButton'); manager.addControl(playPauseButton); playPauseButton.position.y = -0.2; playPauseButton.position.z = -5; playPauseButton.mesh.rotate(BABYLON.Axis.Y, Math.PI, BABYLON.Space.WORLD); playPauseButton.text = "Play/Pause"; playPauseButton.onPointerUpObservable.add(onPlayPauseButtonClicked); // Tone.Meter の値を元に躍動する立方体 MeterBox を作成する // 初期位置は原点から 10m 頭上の場所に設置 const meterBox = BABYLON.MeshBuilder.CreateBox('MeterBox', { size: 1 }, scene); meterBox.position = new BABYLON.Vector3(0, 10, 0); // MeterBox をカッコよく見せるための各種カラー設定を行うための Material 作成 const meterBoxMaterial = new BABYLON.StandardMaterial('MeterBoxMaterial', scene); // MeterBox の表面色の設定 meterBoxMaterial.diffuseColor = new BABYLON.Color3(1, 0, 0); // 特定の方向から来る光を MeterBox が反射するための設定 meterBoxMaterial.specularColor = new BABYLON.Color3(0.5, -0.5, 0.7); // MeterBox 自身が発光する光の設定 meterBoxMaterial.emissiveColor = new BABYLON.Color3(0.6, 0.7, 0.8); // 全体的な MeterBox の明るさ設定 meterBoxMaterial.ambientColor = new BABYLON.Color3(1, 1, 1); meterBox.material = meterBoxMaterial; return scene; } const scene = createScene(); const vrHelper = scene.createDefaultVRExperience(); vrHelper.enableInteractions(); var _rotationAngle = 0; vrHelper.onControllerMeshLoaded.add((webVRController) => { var isHorizontalRotate = false; const angle = Math.PI / 4; webVRController.onPadValuesChangedObservable.add((stateObject) => { if (webVRController.hand === 'left') { var matrix = BABYLON.Matrix.RotationAxis(BABYLON.Axis.Y, angle * _rotationAngle); var move = new BABYLON.Vector3(stateObject.x, 0, -stateObject.y); var addPos = BABYLON.Vector3.TransformCoordinates(move, matrix); vrHelper.position = vrHelper.position.add(addPos) } else { if (isHorizontalRotate && Math.abs(stateObject.x) > 0.8) { isHorizontalRotate = false; stateObject.x > 0 ? _rotationAngle++ : _rotationAngle--; var target = BABYLON.Quaternion.FromRotationMatrix(BABYLON.Matrix.RotationY(angle * _rotationAngle)); vrHelper.currentVRCamera.rotationQuaternion = target } else if (Math.abs(stateObject.x) < 0.8) { isHorizontalRotate = true } } }); }); const box = scene.getMeshByName('Box') // Tone.Meter の値に応じて変化する MeterBox の Mesh 取得 const meterBox = scene.getMeshByName('MeterBox') var time = 0.0; engine.runRenderLoop(() => { box.material.setFloat('time', time); scene.render(); // Tone.Meter で取得した値を元に MeterBox のスケールを最大で 15倍にする const rms = meter.getValue() * 14 + 1; meterBox.scaling = new BABYLON.Vector3(rms, rms, rms); // 常に X軸方向と Y軸方向に回転させておく設定 meterBox.rotation.x += 0.01; meterBox.rotation.y += 0.01; time += 0.01; }); window.addEventListener('resize', function () { engine.resize(); }); </script> </body> </html>
http://localhost:3000
にアクセスしてみます頭上で躍動している立方体の存在が確認出来ていれば OK です
2. 音の高低に応じて地面を躍動させる
次に
Tone.Analyser('fft', 64)
で取得した音の高低スペクトラムを使用して、
地面を音の高低に応じて動的に躍動させるようにしてみますbabylon-js/public/index.html<!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html" charset="utf-8" /> <title>Babylon Club</title> <script src="https://cdn.babylonjs.com/babylon.js"></script> <script src="https://cdn.babylonjs.com/gui/babylon.gui.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/13.8.18/Tone.min.js"></script> <style> html, body { overflow: hidden; width: 100%; height: 100%; margin: 0; padding: 0; } #renderCanvas { width: 100%; height: 100%; touch-action: none; } </style> </head> <body> <canvas id="renderCanvas"></canvas> <script> const canvas = document.getElementById('renderCanvas'); const engine = new BABYLON.Engine(canvas, true); // 音楽の入力信号を RMS で取得出来るようにする const meter = new Tone.Meter('level'); // 音楽の波形をフーリエ変換して音の高さごとのスペクトラムを取得する // 音の高低の強さが getValue 関数を使用すると配列として取得出来るイメージ // 引数の 64 は分解能 (高低の分割数) を指す const fft = new Tone.Analyser('fft', 64); // ↓ のような音楽の波形を取得する // https://info.shimamura.co.jp/digital/knowledge/2014/03/19260/2 // 引数の 256 は分割数を (波形の分割数) を指す const waveform = new Tone.Analyser('waveform', 256); const player = new Tone.Player({ url: '/audio/music.mp3', loop: true, }).connect(meter).fan(fft, waveform).toMaster(); const onPlayPauseButtonClicked = () => { if (player.loaded) { if (player.state == 'started') { player.stop() } else { player.start() } } } const createScene = () => { const scene = new BABYLON.Scene(engine); const camera = new BABYLON.FreeCamera('MainCamera', new BABYLON.Vector3(0, 0, 0), scene); camera.setTarget(BABYLON.Vector3.Zero()); camera.attachControl(canvas, true); const box = BABYLON.MeshBuilder.CreateBox('Box', { size: 20, sideOrientation: BABYLON.Mesh.BACKSIDE }, scene); box.position = BABYLON.Vector3.Zero(); const shaderMaterial = new BABYLON.ShaderMaterial('Floor', scene, './shaders/floor'); box.material = shaderMaterial; const light = new BABYLON.HemisphericLight('AmbientLight', new BABYLON.Vector3(0, 0, 0), scene); const manager = new BABYLON.GUI.GUI3DManager(scene); const playPauseButton = new BABYLON.GUI.HolographicButton('PlayPauseButton'); manager.addControl(playPauseButton); playPauseButton.position.y = -0.2; playPauseButton.position.z = -5; playPauseButton.mesh.rotate(BABYLON.Axis.Y, Math.PI, BABYLON.Space.WORLD); playPauseButton.text = "Play/Pause"; playPauseButton.onPointerUpObservable.add(onPlayPauseButtonClicked); const meterBox = BABYLON.MeshBuilder.CreateBox('MeterBox', { size: 1 }, scene); meterBox.position = new BABYLON.Vector3(0, 10, 0); const meterBoxMaterial = new BABYLON.StandardMaterial('MeterBoxMaterial', scene); meterBoxMaterial.diffuseColor = new BABYLON.Color3(1, 0, 0); meterBoxMaterial.specularColor = new BABYLON.Color3(0.5, -0.5, 0.7); meterBoxMaterial.emissiveColor = new BABYLON.Color3(0.6, 0.7, 0.8); meterBoxMaterial.ambientColor = new BABYLON.Color3(1, 1, 1); meterBox.material = meterBoxMaterial; // 表面が赤色のマテリアルを作成する const redMaterial = new BABYLON.StandardMaterial(scene); redMaterial.diffuseColor = new BABYLON.Color3(1.0, 0, 0); // 表面が緑色のマテリアルを作成する const greenMaterial = new BABYLON.StandardMaterial(scene); greenMaterial.diffuseColor = new BABYLON.Color3(0, 1.0, 0); // 表面が青色のマテリアルを作成する const blueMaterial = new BABYLON.StandardMaterial(scene); blueMaterial.diffuseColor = new BABYLON.Color3(0, 0, 1.0); // Tone.Analyser('fft', 64) で分解能が 64 のため、64 個のメッシュで床を作成する const iteration = 8; // 8 * 8 = 64 (fft.size); for (var i = 0; i < iteration; i++) { for (var j = 0; j < iteration; j++) { // VR クラブのサイズを取得 (x, y, z 同サイズの立方体のため適当に x のサイズを取得) const boxSize = box.getBoundingInfo().boundingBox.extendSize.x; // VR クラブのサイズを分割して 64 個のメッシュが敷き詰められるように、 // 各メッシュのポジションの x と z の値を算出する const splitSize = boxSize / iteration; const splitX = splitSize * i * 2 - boxSize + 1; const splitZ = splitSize * j * 2 - boxSize + 1; // 今回はシリンダーを敷き詰めて床にする // 各シリンダーは算出した x と z の位置に配置していく const groundCylinder = BABYLON.MeshBuilder.CreateCylinder('GroundCylinder', { size: 1 }, scene); groundCylinder.position = new BABYLON.Vector3(splitX, -8, splitZ); groundCylinder.scaling = new BABYLON.Vector3(2, 2, 2); // 何番目に作成したシリンダーかを判定して、 // 赤緑青の三色で各シリンダーの色を規則的に設定する const no = (i * iteration + j) % 3; switch (no) { case 0: groundCylinder.material = redMaterial; break; case 1: groundCylinder.material = greenMaterial; break; case 2: groundCylinder.material = blueMaterial; break; } } } return scene; } const scene = createScene(); const vrHelper = scene.createDefaultVRExperience(); vrHelper.enableInteractions(); var _rotationAngle = 0; vrHelper.onControllerMeshLoaded.add((webVRController) => { var isHorizontalRotate = false; const angle = Math.PI / 4; webVRController.onPadValuesChangedObservable.add((stateObject) => { if (webVRController.hand === 'left') { var matrix = BABYLON.Matrix.RotationAxis(BABYLON.Axis.Y, angle * _rotationAngle); var move = new BABYLON.Vector3(stateObject.x, 0, -stateObject.y); var addPos = BABYLON.Vector3.TransformCoordinates(move, matrix); vrHelper.position = vrHelper.position.add(addPos) } else { if (isHorizontalRotate && Math.abs(stateObject.x) > 0.8) { isHorizontalRotate = false; stateObject.x > 0 ? _rotationAngle++ : _rotationAngle--; var target = BABYLON.Quaternion.FromRotationMatrix(BABYLON.Matrix.RotationY(angle * _rotationAngle)); vrHelper.currentVRCamera.rotationQuaternion = target } else if (Math.abs(stateObject.x) < 0.8) { isHorizontalRotate = true } } }); }); const box = scene.getMeshByName('Box') const meterBox = scene.getMeshByName('MeterBox') const groundCylinders = scene.getMeshesByID('GroundCylinder') var time = 0.0; engine.runRenderLoop(() => { box.material.setFloat('time', time); scene.render(); const rms = meter.getValue() * 14 + 1; meterBox.scaling = new BABYLON.Vector3(rms, rms, rms); meterBox.rotation.x += 0.01; meterBox.rotation.y += 0.01; // 音楽が再生されていれば音の高低の強さに応じて // 各シリンダー(床) を上下に躍動させる if (player.state == 'started') { const fftValues = fft.getValue(); for (var i = 0; i < fft.size; i++) { groundCylinders[i].position.y = fftValues[i] * 0.05; } } time += 0.01; }); window.addEventListener('resize', function () { engine.resize(); }); </script> </body> </html>
http://localhost:3000
にアクセスしてみますのように音楽に合わせて床が躍動していれば成功です
Tone.js で取得した音楽の波形を元にパーティクルを発生させる
Babylon.js には Solid Particle System (SPS) という仕組みが備わっており、
Mesh をパーティクルとして扱うことが可能です今回は SPS を使用して、よりパーティー感のある演出を入れていきます
具体的に言うと SPS を使用して波形の形を表現してみたいと思いますbabylon-js/public/index.html<!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html" charset="utf-8" /> <title>Babylon Club</title> <script src="https://cdn.babylonjs.com/babylon.js"></script> <script src="https://cdn.babylonjs.com/gui/babylon.gui.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/13.8.18/Tone.min.js"></script> <style> html, body { overflow: hidden; width: 100%; height: 100%; margin: 0; padding: 0; } #renderCanvas { width: 100%; height: 100%; touch-action: none; } </style> </head> <body> <canvas id="renderCanvas"></canvas> <script> const canvas = document.getElementById('renderCanvas'); const engine = new BABYLON.Engine(canvas, true); // 音楽の入力信号を RMS で取得出来るようにする const meter = new Tone.Meter('level'); // 音楽の波形をフーリエ変換して音の高さごとのスペクトラムを取得する // 音の高低の強さが getValue 関数を使用すると配列として取得出来るイメージ // 引数の 64 は分解能 (高低の分割数) を指す const fft = new Tone.Analyser('fft', 64); // ↓ のような音楽の波形を取得する // https://info.shimamura.co.jp/digital/knowledge/2014/03/19260/2 // 引数の 256 は分割数を (波形の分割数) を指す const waveform = new Tone.Analyser('waveform', 256); const player = new Tone.Player({ url: '/audio/music.mp3', loop: true, }).connect(meter).fan(fft, waveform).toMaster(); const onPlayPauseButtonClicked = () => { if (player.loaded) { if (player.state == 'started') { player.stop() } else { player.start() } } } const createScene = () => { const scene = new BABYLON.Scene(engine); const camera = new BABYLON.FreeCamera('MainCamera', new BABYLON.Vector3(0, 0, 0), scene); camera.setTarget(BABYLON.Vector3.Zero()); camera.attachControl(canvas, true); const box = BABYLON.MeshBuilder.CreateBox('Box', { size: 20, sideOrientation: BABYLON.Mesh.BACKSIDE }, scene); box.position = BABYLON.Vector3.Zero(); const shaderMaterial = new BABYLON.ShaderMaterial('Floor', scene, './shaders/floor'); box.material = shaderMaterial; const light = new BABYLON.HemisphericLight('AmbientLight', new BABYLON.Vector3(0, 0, 0), scene); const manager = new BABYLON.GUI.GUI3DManager(scene); const playPauseButton = new BABYLON.GUI.HolographicButton('PlayPauseButton'); manager.addControl(playPauseButton); playPauseButton.position.y = -0.2; playPauseButton.position.z = -5; playPauseButton.mesh.rotate(BABYLON.Axis.Y, Math.PI, BABYLON.Space.WORLD); playPauseButton.text = "Play/Pause"; playPauseButton.onPointerUpObservable.add(onPlayPauseButtonClicked); const meterBox = BABYLON.MeshBuilder.CreateBox('MeterBox', { size: 1 }, scene); meterBox.position = new BABYLON.Vector3(0, 7, 0); const meterBoxMaterial = new BABYLON.StandardMaterial('MeterBoxMaterial', scene); meterBoxMaterial.diffuseColor = new BABYLON.Color3(1, 0, 0); meterBoxMaterial.specularColor = new BABYLON.Color3(0.5, -0.5, 0.7); meterBoxMaterial.emissiveColor = new BABYLON.Color3(0.6, 0.7, 0.8); meterBoxMaterial.ambientColor = new BABYLON.Color3(1, 1, 1); meterBox.material = meterBoxMaterial; const redMaterial = new BABYLON.StandardMaterial(scene); redMaterial.diffuseColor = new BABYLON.Color3(1.0, 0, 0); const greenMaterial = new BABYLON.StandardMaterial(scene); greenMaterial.diffuseColor = new BABYLON.Color3(0, 1.0, 0); const blueMaterial = new BABYLON.StandardMaterial(scene); blueMaterial.diffuseColor = new BABYLON.Color3(0, 0, 1.0); const boxSize = box.getBoundingInfo().boundingBox.extendSize.x; const iteration = 8; // 8 * 8 = 64 (fft.size); for (var i = 0; i < iteration; i++) { for (var j = 0; j < iteration; j++) { const splitSize = boxSize / iteration; const splitX = splitSize * i * 2 - boxSize + 1; const splitZ = splitSize * j * 2 - boxSize + 1; const groundCylinder = BABYLON.MeshBuilder.CreateCylinder('GroundCylinder', { size: 1 }, scene); groundCylinder.position = new BABYLON.Vector3(splitX, -10, splitZ); groundCylinder.scaling = new BABYLON.Vector3(2, 2, 2); const no = (i * iteration + j) % 3; switch (no) { case 0: groundCylinder.material = redMaterial; break; case 1: groundCylinder.material = greenMaterial; break; case 2: groundCylinder.material = blueMaterial; break; } } } return scene; } const scene = createScene(); const vrHelper = scene.createDefaultVRExperience(); vrHelper.enableInteractions(); var _rotationAngle = 0; vrHelper.onControllerMeshLoaded.add((webVRController) => { var isHorizontalRotate = false; const angle = Math.PI / 4; webVRController.onPadValuesChangedObservable.add((stateObject) => { if (webVRController.hand === 'left') { var matrix = BABYLON.Matrix.RotationAxis(BABYLON.Axis.Y, angle * _rotationAngle); var move = new BABYLON.Vector3(stateObject.x, 0, -stateObject.y); var addPos = BABYLON.Vector3.TransformCoordinates(move, matrix); vrHelper.position = vrHelper.position.add(addPos) } else { if (isHorizontalRotate && Math.abs(stateObject.x) > 0.8) { isHorizontalRotate = false; stateObject.x > 0 ? _rotationAngle++ : _rotationAngle--; var target = BABYLON.Quaternion.FromRotationMatrix(BABYLON.Matrix.RotationY(angle * _rotationAngle)); vrHelper.currentVRCamera.rotationQuaternion = target } else if (Math.abs(stateObject.x) < 0.8) { isHorizontalRotate = true } } }); }); const box = scene.getMeshByName('Box') const meterBox = scene.getMeshByName('MeterBox') const groundCylinders = scene.getMeshesByID('GroundCylinder') var time = 0.0; engine.runRenderLoop(() => { box.material.setFloat('time', time); const rms = meter.getValue() * 14 + 1; meterBox.scaling = new BABYLON.Vector3(rms, rms, rms); meterBox.rotation.x += 0.01; meterBox.rotation.y += 0.01; if (player.state == 'started') { const fftValues = fft.getValue(); for (var i = 0; i < fft.size; i++) { groundCylinders[i].position.y = fftValues[i] * 0.05; } } scene.render(); time += 0.01; }); // Solid Particle System (SPS) の使用を宣言する const SPS = new BABYLON.SolidParticleSystem('SPS', scene); // ビルボードを有効にする (負荷軽減のため) // http://nn-hokuson.hatenablog.com/entry/2017/03/24/211211 SPS.billboard = true; // パーティクル自体は回転させず、カラーもテクスチャのアップデートしない (負荷軽減のため) SPS.computeParticleRotation = false; SPS.computeParticleColor = false; SPS.computeParticleTexture = false; // 波形を可視化するために使用する平面 WaveformPlain をサイズ 0.1m で作成する const waveformPlane = BABYLON.Mesh.CreatePlane('WaveformPlain', 0.1, scene) const boxSize = box.getBoundingInfo().boundingBox.extendSize.x; // SPS で平面 WaveformPlain を波形解析の分解能 (256個) 扱えるようにする // 各平面は VR クラブ内の横幅いっぱいになるように配置される // 波形の様子が見やすくするように平面群はカメラの正面に配置して、 // 縦幅を 3倍にして、色を配置場所に応じて変化させている SPS.addShape(waveformPlane, waveform.size, { positionFunction: function (particle, i, s) { particle.position.x = (s / waveform.size) * boxSize * 2 - boxSize; particle.position.z = -8; particle.scaling.y = 3.0 particle.color = new BABYLON.Color4( particle.position.y / boxSize + 0.5, particle.position.z / boxSize + 0.5, particle.position.x / boxSize + 0.5, 1.0 ); } }); // WaveformPlain はもう使用しないため破棄する waveformPlane.dispose(); // SPS に追加した平面群を扱う Mesh を作成する const spsMesh = SPS.buildMesh(); // SPS のパーティクルアップデート前に // パーティクルを変化させるのに使用する波形データを // waveformValues 変数に保持しておく // (updateParticle で waveform.getValue() を大量に実行したくないため) var waveformValues; SPS.beforeUpdateParticles = function () { waveformValues = waveform.getValue(); } // SPS のパーティクルを全てアップデートする SPS.updateParticle = function (particle) { // パーティクルのグローバルインデックスは idx という変数で取得可能 const waveformValue = waveformValues[particle.idx] particle.position.x = (particle.idx / waveform.size) * boxSize * 2 - boxSize; // 波を表現するためにパーティクルの位置 Y を波形に合わせて上下させる particle.position.y = waveformValue * boxSize; particle.color = new BABYLON.Color4( particle.position.y / boxSize + 0.5, particle.position.z / boxSize + 0.5, particle.position.x / boxSize + 0.5, 1.0 ); } // シーンのレンダリング前に SPS で管理している // パーティクルをアップデートする (SPS.updateParticle が呼ばれる) scene.registerBeforeRender(() => { SPS.setParticles(); }) window.addEventListener('resize', function () { engine.resize(); }); </script> </body> </html>
http://localhost:3000
にアクセスしてみます音楽を再生した時に目の前の平面の行列が波形っぽく動いていれば成功です
ある程度音楽に合わせた演出が出来てきたので、実際に Quest で見てみましょうQuest で動作確認する
それでは 前回と同様の手順 で現状の VR クラブ内の様子を Quest で確認します
無事に Quest 内でも見られました
多少パフォーマンスにも気を使いながら改修してきたので、
Quest でもちゃんと FPS は安定して出てるように感じましたQuest で見た感じ躍動する床のシリンダーがそれなりに迫力ありましたが、
目の前の波形や頭上の躍動する立方体はそれっぽい感があるだけで
あまり迫力や臨場感は感じませんでしたおわりに
今回は Tone.js を使って実際に音楽にマッチした演出を導入してみました
本当は波形解析したデータを利用して、更にダイナミックな演出を取り入れる予定だったのですが、
めちゃくちゃ本質とは関係ないところで時間を食ってしまいそうだったため断念しました次回は折角 Tone.js を使用しているので音楽にリバーブ掛けてみたり、
ユーザが何らかのアクションを起こすと音楽に介入できる仕組みを作る予定です参考リンク
- 投稿日:2019-07-14T20:33:54+09:00
Unityの状態管理フレームワークを作ってみた
「Unityで状態管理するのしんどくね??」
ってなったので状態管理をするフレームワークを作ってみました.作った経緯
最近結構Webフロントエンドの開発をメインにやっているのですが, 先日SPAJAM東北予選にて久々にUnityを使ったのですが 「状態管理しんどくない??」 「UIに値反映するのダルすぎなんだけど??」 っと見事にWebフロントエンドのフレームワーク慣れしてしまった体が拒否反応を起こしてしまったので後日,状態管理フレームワークを作ってみました.
似たようなものに「Unidux」っというReduxをUnityで実装しているものもあるのですが,個人的に他ライブラリへの依存が嫌だったの自分でも作ってみました.
参考にしたもの
Webフロントエンドの状態管理アーキテクチャ・フレームワークというと 「Flux」や「Redux」などがありますが,今回は「The Elm Architecture」をリスペクトして実装しました.
出来たもの
その名も 「UniTEA」 です.
使い方
おそらく一番簡単なカウンターアプリの実装を見ながら使い方を紹介していきます.
0. インストール方法
Unity Package Manager を利用しているのでプロジェクトルート以下にある
Packages/manifest.json
に{ "dependencies" : { ... "com.uzimaru0000.unitea": "https://github.com/uzimaru0000/UniTEA.git", ... } }と追記してエディタに行くとインストールされます.
1. Modelとなる構造体を作る
アプリケーションの状態となるModelを定義します.
今回はカウンターアプリなので以下のようなものでいいでしょうpublic struct Model { public int count; }注意してほしいのは構造体で定義してください.
2. Modelの変更を伝えるMessageを作る
Modelの変更をフレームワーク側に伝えるMessageを定義します.
今回は
- カウントアップ
- カウントダウン
の2つでいいと思います.
public enum Msg { Increase, Decrease }enumで定義してください.
3. Messageをラップしたclassを作る
Messageをラップしたclassを作成します.
このとき,このclassにはIMessenger<Msg>
というインターフェースを実装してください.
Msg GetMessage()
というメソッドを実装すれば大丈夫です.返り値には2
で定義したMessageを返してください.using UniTEA; public class IncreaseMsg : IMessenger<Msg> { public Msg GetMessage() => Msg.Increase; } public class DecreaseMsg : IMessenger<Msg> { public Msg GetMessage() => Msg.Decrease; }4. Updaterを作る
ModelをUpdateするためのメソッドを定義したclassを作成します.
IUpdater<Model, Msg>
というインターフェースを実装してください.
このインターフェイスは(Model, Cmd<Msg>) Update(IMessenger<Msg> msg, Model model)
というメソッドを定義します.C# 7から実装されたTuple
です.Cmd<Msg>
は非同期処理を表す型です.今回はCmd<Msg>.none
という値を使います.using UniTEA; public class Updater : IUpdater<Model, Msg> { public (Model, Cmd<Msg>) Update(IMessenger<Msg> msg, Model model) { switch(msg) { case IncreaseMsg _: return (new Model { count = model.count + 1 }, Cmd<Msg>.none); case DecreaseMsg _: return (new Model { count = model.count - 1 }, Cmd<Msg>.none); default: return (model, Cmd<Msg>.none); } } }状態が変わるときは新しいModelを生成しましょう.
5. Viewを更新するRendererを作る
ModelからView(UIなど)の見た目を変更するclassを作ります.
IRenderer<Model>
というインターフェースを実装してください.
このインターフェイスは,void Render(Model model)
というメソッドを実装します.using UnityEngine; using UnityEngine.UI; using UniTEA; public class Renderer : MonoBehaviour, IRenderer<Model> { [SerializeField] Text display; public void Render(Model model) { display.text = model.count.ToString(); } }今回は
Textコンポーネント
にModel
のcount
の値を入れているだけですね.6. イベントを発火されるためのclassを作る
イベント発火用のDispatcherクラスを作ります. このclass自体は特に実装するインターフェイスは無いので任意のMonoBehaviourクラス等でイベントを設定したり,publicメソッドにしてuGUIのインスペクタから設定してもいいです.また,あとで出てくる
UniTEA
クラスを管理するクラスをインスペクタから設定する・シングルトンにする・DIライブラリを使う等で参照出来るようにしてください.using UnityEngine; using UnityEngine.UI; using UniTEA; public class Dispatcher : MonoBehaviour { [SerializeField] Manager manager; [SerializeField] Button increaseButton; [SerializeField] Button decreaseButton; void Start() { increaseButton.onClick.AddListener(() => manager.Dispatch(new IncreaseMsg())); decreaseButton.onClick.AddListener(() => manager.Dispatch(new DecreaseMsg())); } }7. UniTEAクラスのマネージャークラスを作る
フレームワークのCoreである
UniTEA
クラスを管理するマネージャークラスを作ります.
管理と言っても初期化とDispatchメソッドを公開するだけです.using UniTEA; using UnityEngine; public class Manager : MonoBehaviour { UniTEA<Model, Msg> _instance; UniTEA<Model, Msg> instance { get { if (_instance == null) { _instance = new UniTEA<Model, Msg>( () => (new Model(), Cmd<Msg>.none), new Updater(), renderer); } return _instance; } } [SerializeField] new Renderer renderer; public void Dispatch(IMessenger<Msg> msg) { instance.Dispatch(msg); } }8. Unityのオブジェクトにattachする
Renderer
,Dispatcher
,Manager
を任意のGameObjectにattachしてインスペクターから依存のあるものを注入していきます.これで以下のような動作をするカウンターができます!!
Counterの実装できた! pic.twitter.com/z5AMiEbE61
— うじまる? (@uzimaru0000) July 2, 2019詳しい説明
Messageをラップする理由
Messageに値を含ませるためです.
例えばInputFieldに入力した値をMessageに含ませるとき,enumをMessageに使うと値をUpdateに伝えることができません.
なので以下のようなコードでMessageをラップしたクラスに値を含ませてあげます.public enum Msg { Input } public class InputMsg : IMessenger<Msg> { public string value; public Msg GetMessage() => Msg.Input; public InputMsg(string value) { this.value = value; } }これでInputMsgに入力された値を含ませることができます.
ただ,値を含めるためのMessageを作る際に上のようなコードを書かなければいけないので
UniTEA.Utils
namespaceの中にOneValueMessage<T, U>
というクラスがあるのでpublic class InputMsg : OnValueMessage<Msg, string> { public override GetMessage() => Msg.Input; public InputMsg(string value): base(value) {} }っとすれば最初のコードと同じになります.
Cmd<Msg> って?
Cmd<Msg>
型は非同期処理を表す型です.Task型をラップしている型になります.
作成するにはコンストラクタにTask<IMessenger<Msg>>
を渡してあげます.他にも質問があったらコメントでお願いします
最後に
最初の方でも述べましたがこのライブラリ自体は他のライブラリへの依存はないです.ですが,CmdにTaskを渡すために「UniTask」を使ったり,Dispatcherの部分に「UniRx」をつかったり,DIをするために「Zenject」を使うのはいいと思います.むしろそのほうがいいかもしれないです.
また,コアの部分の開発にそんなに時間がかかってないので「ここをもっとこうしたらいいと思う」「この部分はなんでこうなの?」っと言ったissue, PullRequest お待ちしてます!!
- 投稿日:2019-07-14T15:01:59+09:00
【Unity(C#)】オブジェクトに当たると途切れるレーザーの実装方法
レーザー
Ray
とLineRenderer
で実装します。
Ray
がオブジェクトに当たったかどうかの判定を行い、LineRenderer
の終点の位置を変えることで
レーザーの途切れを表現します。そのためには
Ray
とLineRenderer
の始点、終点を合わせておく必要があります。RayとLineRendererの位置を合わせるvoid OnRay() { float lazerDistance = 10f; Vector3 direction = hand.transform.forward * lazerDistance; Vector3 pos = hand.transform.position; RaycastHit hit; Ray ray = new Ray(pos, hand.transform.forward); lineRenderer.SetPosition(0,hand.transform.position); if (Physics.Raycast(ray, out hit, lazerDistance)) { lineRenderer.SetPosition(1, pos + direction); } Debug.DrawRay(ray.origin, pos + direction, Color.red, 0.1f); }画像のようにぴったりと合わせることができました。(赤い線=
Ray
,青い線=LineRenderer
)
貫通してしまう
先程のコードのままでは画像のようにオブジェクトを貫通してしまいます。
この問題を解決するうえで、
Ray
とLineRenderer
をぴったりと合わせたことが鍵となります。
Ray
はオブジェクトとの衝突判定を行うことができます。
また、オブジェクトと衝突した座標を取得することもできます。つまり、
LineRenderer
の終点にRay
が衝突した座標を当てはめれば
オブジェクトに当たった箇所でレーザー(LineRenderer)が途切れているように見せることができるということです。コードに落とし込むとこうです。
if (Physics.Raycast(ray, out hit, lazerDistance)) { hitPos = hit.point; //オブジェクトとの衝突座標を取得 lineRenderer.SetPosition(1, hitPos); //LineRendererの終点に当てはめる }デモ
遮蔽物でレーザーが途切れているように見えます。ばっちりです。
デモにおいて、始点も少しずらしているので
始点のずらし方に関しても最終的なコードと合わせて記述していきます。最終的なコード
using UnityEngine; [RequireComponent(typeof(LineRenderer))] public class Lazer : MonoBehaviour { [SerializeField] GameObject hand; LineRenderer lineRenderer; Vector3 hitPos; Vector3 tmpPos; float lazerDistance = 10f; float lazerStartPointDistance = 0.15f; float lineWidth = 0.01f; void Reset() { lineRenderer = this.gameObject.GetComponent<LineRenderer>(); lineRenderer.startWidth = lineWidth; } void Start() { lineRenderer = this.gameObject.GetComponent<LineRenderer>(); lineRenderer.startWidth = lineWidth; } void Update() { OnRay(); } void OnRay() { Vector3 direction = hand.transform.forward * lazerDistance; Vector3 rayStartPosition = hand.transform.forward*lazerStartPointDistance; Vector3 pos = hand.transform.position; RaycastHit hit; Ray ray = new Ray(pos+rayStartPosition, hand.transform.forward); lineRenderer.SetPosition(0,pos + rayStartPosition); if (Physics.Raycast(ray, out hit, lazerDistance)) { hitPos = hit.point; lineRenderer.SetPosition(1, hitPos); } else { lineRenderer.SetPosition(1, pos + direction); } Debug.DrawRay(ray.origin, ray.direction * 100, Color.red, 0.1f); } }始点をずらす
始点をずらしているのは下記の箇所です。
Vector3 rayStartPosition = hand.transform.forward*lazerStartPointDistance; Ray ray = new Ray(pos+rayStartPosition, hand.transform.forward); Vector3 pos = hand.transform.position; lineRenderer.SetPosition(0,pos + rayStartPosition);Shaderで始点をずらしているようにみせる1こともできますが、
今回は始点そのものの座標を調節してます。手にコライダーが付いている状態で手のTransformを参照して
Ray
を出した場合、
手のPivot、コライダーの大きさによっては
Ray
が手のコライダーに衝突してしまって判定が取れない場合があります。
そもそものRay
の始点をずらすことで意図しない衝突を回避できます。
- 投稿日:2019-07-14T12:10:46+09:00
低価格でも高クォリティな最強アセット13選!
この記事は「Unityアセット真夏のアドベントカレンダー2019」8月15日の記事になります。
Unity アセット真夏のアドベントカレンダー 2019 Summer!
私が現在持っているアセットの中で5~10ドル程のおすすめを紹介します!
目次
1.Modern UI Pack
2.Sun Flares
3.Materialize FX
4.Simple Physics Toolkit
5.Animal pack deluxe
6.Wet Road Materials
7.AdvancedMissile
8.Realistic Grass and Bush Pack2
9.Zero Gravity Part One
10.FPS Online Shooter Example
11.100 Post Processing Styles
12.Town Constructor Pack
13.Simple Mesh Cutter1.Modern UI Pack
Modern UI Pack
カテゴリ:GUI
価格:$5モダンでオサレなアニメーション付きUIパックです。
ボタン、アイコン、スイッチ、スライダー、ドロップダウン、テキストエリアなど多くの種類があります。
さらにHierarchy右クリックから簡単に作れます。
量がすごい!!!!
デフォルトにはないようなスライダー
とってもオシャレなアイコン付き!
2.Sun Flares
Sun Flares
カテゴリ:テクスチャ&マテリアル
価格:4.99太陽を見たときに見える丸い光のテクスチャです。
あれって光芒って言うらしいです。昼バージョンと夕方バージョンがあります。
つけるだけでとてもそれっぽくなるので是非ご活用ください!光らせたいオブジェクトにLens Flareコンポーネントをセットします。
3.Materialize FX
Materialize FX
カテゴリ:シェーダー
価格:$9崩壊や生成に使えるシェーダーです。魔法っぽいのとSFっぽい奴の2種類があります。
さらにノードベースのシェーダー作成アセットのAmplify Shader Editor に対応してるとのこと。
※注意 このアセットはシェーダーモデル3.0以上に対応しているGPUが必要です。
4.Simple Physics Toolkit
Simple Physics Toolkit
カテゴリ:物理エンジン
価格:$11.99様々な物理エンジンのスクリプト詰め合わせのアセットです。
磁石、爆発、推進エンジン、物体の発射、風、バウンドプレート、浮力、無重力、オブジェクトの破壊、インタラクティブアイテムの10種類です。5.Animal pack deluxe
Animal pack deluxe
カテゴリ:3D>キャラクター>動物
価格:$526種類の動物を追加するアセットです。テクスチャはPBR対応で、さらにすべての動物に待機、走る、歩く、攻撃、食べる、死ぬのアニメーション付き!
このクォリティで$5はとても安いと思います。サメやワニ、牛
ヤギやクマ、ブタ
アヒル、ニワトリ、ウサギ、サケ
ヘビやネズミ、サソリ
カタツムリ、カエル、カニなどなど
ちなみにこのアセット作者さんは他の動物や恐竜、水生生物のアセットも5ドルで販売していま!
6.Wet Road Materials
Wet Road Materials
カテゴリ:2D>テクスチャ&マテリアル>道路
価格:$4.99濡れた道路を追加するアセットです。
2048pxの高解像度テクスチャでとてもリアルな濡れた道路が表現可能!マテリアルのパラメータを変えるだけで簡単に道路の質感を変えることが可能です。
7.AdvancedMissile
AdvancedMissile
カテゴリ:ツール
価格:$5日本人の方が作成したミサイルを追加するアセットです。
通常のミサイルの他に、誘導ミサイルや
カクカクに動くSFっぽい挙動までパラメータを調整することで簡単にできます!
日本語ドキュメントが付いているのもとてもありがたいです!8.Realistic Grass and Bush Pack2
Realistic Grass and Bush Pack2
カテゴリ:3D>植物>草木
価格:$4.99このアセットは低ポリでリアルな草花を生やすことができます。
また、このアセット作者さんは他にも多くの植物のアセットを$5や$10で公開していますので、ぜひチェックしてみてください!
High Quality Grass and Bush Pack
Realistic Grass and Bush Pack3
などなど
他にもいっぱい公開されているので是非チェックしてみてください!9.Zero Gravity Part One
Zero Gravity Part One
カテゴリ:3D>環境>SF
価格:$10.15PBR対応の宇宙船の内部のアセットです。
壁や窓、小物など合計59個のプレハブが入っています。
SFっぽい表現をしたい時は是非!10.FPS Online Shooter Example
FPS Online Shooter Example
カテゴリ:テンプレート>パック
価格:$4.99Photonを使用して作られた簡単なオンラインFPSゲームの完成プロジェクトです。
ほかの完成プロジェクトに比べたらシンプルですが、スクリプトを見て学ぶには充分な教材だと思います。
オンラインマルチプレイゲームを作って見たい方はぜひ買って学んで見てはどうでしょうか!11.100 Post Processing Styles
100 Post Processing Styles
カテゴリ:ビジュアルエフェクト>シェーダー>カメラエフェクト
価格:$4.99PostProcessingを使ってみたい!
でもどうすればいいのかわからない!
そんな時に使えるのがこのアセット。
GUIを開いて気になったものをクリックするだけ!エディタ上でプレビューを見ながら気になったものをクリックするだけ!
アップグレードバージョンの1000+ Fast Post Processingもあります。
12.Town Constructor Pack
Town Constructor Pack
カテゴリ:3D>環境>都市
価格:$4.99ビルや街の小物を追加する巨大アセットです。
100以上の建築用モジュールパーツ、50以上の道路の小物、11の背景用の低解像度建築物などが含まれています。13.Simple Mesh Cutter
Simple Mesh Cutter
カテゴリ:ツール>エフェクト
価格:$10ゲーム内でメッシュを切断したい!
それが簡単にできてしまうアセットです。
アニメーションにも対応していて、切断後もボーンを使うことが出来るため、切断後死亡モーションを入れるといった使い方もできそうです。