【山崎研Tips】Unityの当たり判定:サンプル③ブロックに接触するとシリアル通信で文字を送信
※このページは,コミュニケーションロボティクス研究室および神奈川工科大学 電気電子情報工学科 および ホームエレクトロニクス開発学科の研究室チュートリアル用のページです。
https://gyazo.com/a800a2c0398e77e0e068225ad320d852
1. はじめに
研究室では2024年度から「格闘技を理解するAI」の開発に取り組んでいます。その一環で、柔道の形(かた)のVRトレーニングシステムを開発しています。このページでは、これまでに学んだサンプルコードの応用編として、シリアル通信と組み合わせたサンプルコードを用意しました。このコードでは、ブロックに接触するとシリアル通信でコマンドが送信されます。マイコンを利用すれば、カンタンにVRの物理現象を実世界と連携させることができます。このサンプルコードは、下記のUnityのシリアル通信を学んていることを前提としています。
参考:
各種シリアル通信のまとめ
Unity マイコン間のシリアル通信(送受信)
サンプル1:プレーヤーがブロックと接触するとそのブロックが消え、新しいブロックが1つ出現する。(上の動画。このページの内容です。)
サンプル2:プレーヤーがブロックと接触するとそのブロックが消え、接触したブロックに応じて異なる数の新しいブロックが出現する。
※サンプルプロジェクトはドライブの 開発資料\Unity\衝突判定サンプルに置いてあります。
2. コードの概要
2.1 サンプルコードでできること
【山崎研Tips】Unityの当たり判定:サンプル①接触したブロックが消えて新しいブロックが1つ出現にシリアル通信を組み込み
キー入力でPlayer(球体)を操作
ブロックに衝突すると、ブロックが消えてシリアル通信でコマンドを送信
Arduinoが
2.2 構成ファイル
table:構成ファイル
ファイル名 役割
BlockSpawn.cs ブロックの生成管理
PlayerScript.cs プレイヤー(ボール)の移動と接触判定
SerialController.cs シリアル通信の送信処理
3 セットアップの手順
3.1 M5 Atom Lite側の設定
※Unity マイコン間のシリアル通信(送受信)の「 3.1 M5 Atom Lite側の設定」と同様です。
下記のサンプルコードを書き込んでおく。
M5 Atom Lite(Arduino) 側のサンプルコード
送信:Atom Lite のボタンを押すと 0~3 を送信
受信:Unity から送信された文字に応じてとLEDが変化
code: M5Atomlite_Serial_LED
#include <M5Atom.h>
static int sendState = 0;
void setup() {
M5.begin(true, false, true);
Serial.begin(115200);
M5.dis.setBrightness(40);
M5.dis.drawpix(0, CRGB::Black);
}
void loop() {
M5.update();
if (Serial.available()) {
char c = Serial.read();
switch (c) {
case '0': M5.dis.drawpix(0, CRGB::Black); break;
case '1': M5.dis.drawpix(0, CRGB::Green); break;
case '2': M5.dis.drawpix(0, CRGB::Red); break;
case '3': M5.dis.drawpix(0, CRGB::Blue); break;
}
}
if (M5.Btn.wasPressed()) {
sendState = (sendState + 1) % 4;
Serial.println(sendState);
}
}
3.2 Unitry側の設定
Step 1 新規プロジェクトの作成 ※Unity マイコン間のシリアル通信(送受信) 3.2 Step 1 と同様
Unity Hub を起動 → New project をクリック
Editor version は 2022.x以降のものを指定
テンプレートは 3D を選択
プロジェクト名と保存先を決めて 「作成」
Step 2 Player 設定(.NET関連)  【重要!】 ※Unity マイコン間のシリアル通信(送受信) 3.2 Step 2 と同様
Edit → Project Settings… → Player Settings… → Other Settings
https://gyazo.com/6cdc50718aed56a288ceea29b96771a5
Configurationの項目で下記を設定
Api Compatibility Level: .NET Framework
https://gyazo.com/35357e2fe32a0d5356f7f454aeb31953
この手順を抜かすとシリアル通信の際にSystem.IO.Portsに関連するエラーが出ます。
Step 3 各スクリプトの作成
Project ウィンドウで右クリック → Create → Folder → Scripts を作成。
https://gyazo.com/36bcbbeb5498e27ea4ed27b0f1ae522e
Scripts 内で右クリック → Create → C# Script → 名前を 変更
C#ファイルををダブルクリックで開き、サンプルコードに差し替え
作成するファイルと各コードは下記のとおり
PlayerScript.cs プレイヤー(ボール)の移動と接触判定
BlockSpawn.cs ブロックの生成管理
SerialController.cs シリアル通信の送信処理
code: BlockSpawn.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BlockSpawn : MonoBehaviour
{
public static BlockSpawn instance;
public GameObject[] prefabArray;
public void Awake()
{
if (instance == null)
{
instance = this;
}
}
void Start()
{
int idx = Mathf.Clamp(PlayerScript.instance.count, 0, prefabArray.Length - 1);
Instantiate(prefabArrayidx);
}
public void Generate()
{
if (PlayerScript.instance.count < prefabArray.Length)
{
Instantiate(prefabArrayPlayerScript.instance.count);
}
else
{
// すべて出し終わった場合に何かするならここに
// 例:SerialController.Instance?.SendLine("CLEAR");
}
}
}
code: PlayerScript.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerScript : MonoBehaviour
{
public static PlayerScript instance;
public int count;
public void Awake()
{
if (instance == null)
instance = this;
}
void Update()
{
float x = Input.GetAxisRaw("Horizontal") * Time.deltaTime * 2;
transform.position += new Vector3(x, 0, 0);
if (Input.GetKey(KeyCode.LeftShift)) transform.Translate(-0.1f, 0f, 0f);
if (Input.GetKey(KeyCode.RightShift)) transform.Translate(0.1f, 0f, 0f);
}
void OnTriggerEnter(Collider other)
{
if (other.gameObject.CompareTag("Block"))
{
// シリアル送信(「今のカウント + 1」を送る)
if (SerialController.Instance != null)
{
// 例:HIT:1, HIT:2 ... を送る。受信側は数字だけに反応する。
SerialController.Instance.SendLine($"HIT:{count+1}");
}
Destroy(other.gameObject);
count++;
BlockSpawn.instance.Generate();
}
}
}
code: SerialController.cs
using System;
using System.IO.Ports;
using System.Threading;
using System.Collections.Concurrent;
using UnityEngine;
public class SerialController : MonoBehaviour
{
public static SerialController Instance;
public string portName = "COM3";
public int baudRate = 115200;
private SerialPort _port;
private readonly ConcurrentQueue<string> _txQueue = new ConcurrentQueue<string>();
private Thread _worker;
private volatile bool _running;
void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else Destroy(gameObject);
}
void OnEnable() => TryOpen();
void OnDisable() => Close();
void OnApplicationQuit() => Close();
public void SendLine(string line)
{
if (!string.IsNullOrEmpty(line))
_txQueue.Enqueue(line + "\n");
}
private void TryOpen()
{
try
{
_port = new SerialPort(portName, baudRate)
{
NewLine = "\n",
DtrEnable = true,
RtsEnable = true,
WriteTimeout = 100
};
_port.Open();
_running = true;
_worker = new Thread(() =>
{
while (_running)
{
if (_txQueue.TryDequeue(out var s))
_port.Write(s);
Thread.Sleep(1);
}
});
_worker.IsBackground = true;
_worker.Start();
Debug.Log($"Serial opened: {portName}");
}
catch (Exception e)
{
Debug.LogError($"Serial open failed: {e.Message}");
}
}
private void Close()
{
_running = false;
try { _worker?.Join(200); } catch { }
if (_port?.IsOpen == true) _port.Close();
Debug.Log("Serial closed");
}
}
Step 4 判定ブロックCube_0、Cube_1、Cube_2の作成
※【山崎研Tips】Unityの当たり判定:サンプル①接触したブロックが消えて新しいブロックが1つ出現 4.2 Step 1 と同様
Cube_0、Cube_1、Cube_2を作成
「Block」タグを付与
「Tranceform」で位置をそれぞれX=2、X=4、X=6に指定
「Block」タグをつけてPrefab化(「Prefabs」フォルダをつくり、その中にPrefabを入れる)
Step 5 BlockSpawnerをシーンに組み込む
空のオブジェクトを作成
Hierarchyウィンドウで右クリック→「Create Emputy」で空のオブジェクトを作成→名前を 「BlockManager」に
BlockManagerにBlockSpawner.csをアタッチ
BlockManagerをクリックし(下図①)、Inspector のPrefab Array のサイズを「3」に指定(下図②)
「▼Prefab Array」の「▼」を開き(下図③)、各ElemetにCubeのPrefabを指定(下図④)
https://gyazo.com/af4c26abbb3c632a65a92f613e60681f
Step 6 SerialControllerをシーンに組み込む
空のオブジェクトを作成
Hierarchyウィンドウで右クリック→「Create Emputy」で空のオブジェクトを作成→名前を 「SerialManager」に
https://gyazo.com/fab90a372fa9698b2ca4ba1b4e0a1514
SerialManagerにSerialController をドラッグ&ドロップしてSerialControllerをアタッチ
https://gyazo.com/cc24a8748e9c6e2e26fd52029d27860d
SerialManagerをクリックしてInspector のPort Name に デバイスマネージャで確認した COMx を入力(例:COM3)
https://gyazo.com/aa97b69e29413fc44a93cfce52805bc8
Step 7 Sphere(Player)を作成
当たり判定としてTriggerを使用するため、「Is Trigger(トリガーにする)」をセット
Inspectorの「Sphere Collider」→「Is Trigger」にチェック
https://gyazo.com/6fdd6b7f7c56af21497174a1062fed7f
リジッドボディを追加する
Inspectorの一番下の「Add Component」→「Physics」→「Rigidbody」
https://gyazo.com/68383238d04051f03bae88b6e166ca4b
「Use Gravity」のチェックを外す(重力は使用しない)
https://gyazo.com/6fdd6b7f7c56af21497174a1062fed7f
Step 8 シーンを保存
Hierarchyウィンドウの「SampleScene」右クリックし→「Save Scene」でシーンを保存
4. 実行テスト
Arduino の シリアルモニタを閉じていることを確認
Unity でシーンを開き、Play。
←、↑、→、↓ キーで Player(球体)を操作
ブロックに衝突すると、ブロックが消えシリアル通信でコマンドを送信
5. 既存プロジェクトへの組み込み
「Player Settings」を「.NET Framework」に  (3.2 Step 2 Player 設定)
SerialController.cs を任意のシーンの GameObject にアタッチ。
※付録. マイコン2つ(COMポートを2つ)とつなげたい場合
SerialController.csの代わりに下のコードを用いる。
使い方
既存のSerialController.csのコードを下記のものに差し替え。
Inspector の portNames に 2 台分の COM 名 を設定(例:COM5, COM6。macOSは /dev/tty.usb* など)。
COMポートを指定してコマンドを送信(2つのCOMに別々にコマンドを送れます)
詳しくはお問い合わせください
code: SerialController.cs
using System;
using System.IO.Ports;
using System.Threading;
using System.Collections.Generic;
using System.Collections.Concurrent;
using UnityEngine;
public class SerialController : MonoBehaviour
{
public static SerialController Instance;
Header("Serial Ports")
Tooltip("送信先ポートを順番に指定。例) Windows: COM5, COM6 / macOS: /dev/tty.usbmodem1101")
public string[] portNames = new string[] { "COM3", "COM4" };
Header("Settings")
public int baudRate = 115200;
public bool dtrEnable = true;
public bool rtsEnable = true;
public int writeTimeoutMs = 100;
public string newLine = "\n"; // 行末コード(SendLine系で付与)
private readonly List<SerialPort> _ports = new List<SerialPort>();
private readonly Dictionary<string,int> _nameToIndex = new Dictionary<string,int>(StringComparer.OrdinalIgnoreCase);
// 送信アイテム。targetIndex が null の場合はブロードキャスト(全ポート送信)
private struct TxItem
{
public int? targetIndex;
public string data;
}
private readonly ConcurrentQueue<TxItem> _txQueue = new ConcurrentQueue<TxItem>();
private Thread _worker;
private volatile bool _running;
void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else { Destroy(gameObject); }
}
void OnEnable() => TryOpenAll();
void OnDisable() => CloseAll();
void OnApplicationQuit() => CloseAll();
// ======== 送信API ========
/// <summary>全ポートへ1行送信(末尾に改行付加)</summary>
public void SendLine(string line)
{
if (string.IsNullOrEmpty(line)) return;
_txQueue.Enqueue(new TxItem { targetIndex = null, data = line + newLine });
}
/// <summary>全ポートへ生データ送信(改行付加しない)</summary>
public void SendRaw(string data)
{
if (string.IsNullOrEmpty(data)) return;
_txQueue.Enqueue(new TxItem { targetIndex = null, data = data });
}
/// <summary>拡張:指定インデックスのポートへ1行送信(末尾に改行付加)</summary>
public void SendLineToIndex(int index, string line)
{
if (string.IsNullOrEmpty(line)) return;
if (!IsValidIndex(index)) { Debug.LogWarning($"Serial Invalid port index {index}"); return; }
_txQueue.Enqueue(new TxItem { targetIndex = index, data = line + newLine });
}
/// <summary>拡張:指定インデックスのポートへ生データ送信</summary>
public void SendRawToIndex(int index, string data)
{
if (string.IsNullOrEmpty(data)) return;
if (!IsValidIndex(index)) { Debug.LogWarning($"Serial Invalid port index {index}"); return; }
_txQueue.Enqueue(new TxItem { targetIndex = index, data = data });
}
/// <summary>拡張:指定ポート名へ1行送信(末尾に改行付加)</summary>
public void SendLineTo(string portName, string line)
{
if (string.IsNullOrEmpty(line)) return;
if (!TryGetIndex(portName, out int idx)) { Debug.LogWarning($"Serial Unknown port '{portName}'"); return; }
_txQueue.Enqueue(new TxItem { targetIndex = idx, data = line + newLine });
}
/// <summary>拡張:指定ポート名へ生データ送信</summary>
public void SendRawTo(string portName, string data)
{
if (string.IsNullOrEmpty(data)) return;
if (!TryGetIndex(portName, out int idx)) { Debug.LogWarning($"Serial Unknown port '{portName}'"); return; }
_txQueue.Enqueue(new TxItem { targetIndex = idx, data = data });
}
// ======== 内部処理 ========
private bool IsValidIndex(int i) => (i >= 0 && i < _ports.Count);
private bool TryGetIndex(string name, out int idx)
{
if (string.IsNullOrWhiteSpace(name)) { idx = -1; return false; }
return _nameToIndex.TryGetValue(name.Trim(), out idx);
}
private void TryOpenAll()
{
CloseAll(); // 念のためクリーンスタート
_nameToIndex.Clear();
for (int i = 0; i < portNames.Length; i++)
{
var pn = portNamesi;
if (string.IsNullOrWhiteSpace(pn)) continue;
try
{
var sp = new SerialPort(pn.Trim(), baudRate)
{
NewLine = newLine,
DtrEnable = dtrEnable,
RtsEnable = rtsEnable,
WriteTimeout = writeTimeoutMs
};
sp.Open();
_ports.Add(sp);
_nameToIndexsp.PortName = _ports.Count - 1; // 実際に開けたPortNameで登録
Debug.Log($"Serial Opened {sp.PortName} (idx={_ports.Count-1})");
}
catch (Exception e)
{
Debug.LogError($"Serial Open failed on {pn}: {e.Message}");
}
}
if (_ports.Count > 0)
{
_running = true;
_worker = new Thread(WorkerLoop) { IsBackground = true };
_worker.Start();
}
else
{
Debug.LogWarning("Serial No ports opened. Check portNames.");
}
}
private void WorkerLoop()
{
while (_running)
{
try
{
if (_txQueue.TryDequeue(out var item))
{
if (item.targetIndex.HasValue)
{
// 個別送信
var idx = item.targetIndex.Value;
if (IsValidIndex(idx))
{
var sp = _portsidx;
try
{
if (sp != null && sp.IsOpen) sp.Write(item.data);
else Debug.LogWarning($"Serial Port (idx={idx}) not open.");
}
catch (Exception ex)
{
Debug.LogWarning($"Serial Write error on {_portsidx?.PortName}: {ex.Message}");
}
}
else
{
Debug.LogWarning($"Serial Invalid target index {idx}");
}
}
else
{
// 同報送信(全ポート)
for (int i = _ports.Count - 1; i >= 0; i--)
{
var sp = _portsi;
try
{
if (sp != null && sp.IsOpen) sp.Write(item.data);
else Debug.LogWarning($"Serial Port not open (idx={i}).");
}
catch (Exception ex)
{
Debug.LogWarning($"Serial Write error on {_portsi?.PortName}: {ex.Message}");
}
}
}
}
else
{
Thread.Sleep(1);
}
}
catch (Exception ex)
{
Debug.LogWarning($"Serial Worker loop error: {ex.Message}");
Thread.Sleep(5);
}
}
}
private void CloseAll()
{
_running = false;
try { _worker?.Join(200); } catch {}
foreach (var sp in _ports)
{
try
{
if (sp != null && sp.IsOpen) sp.Close();
sp?.Dispose();
} catch {}
}
_ports.Clear();
_nameToIndex.Clear();
Debug.Log("Serial Closed all");
}
}
PlayerScript.cs の送信例(void OnTriggerEnter(Collider other))
「左手への当たり判定でCOM5へ '1'、右手への当たり判定でCOM6へ '2' を送る」など、ポート個別に送信するパターン例を2通り示します。
1) インデックス指定で送る(portNames0 が1台目、portNames1 が2台目)
code:sample01
using UnityEngine;
public class PlayerScript : MonoBehaviour
{
void OnTriggerEnter(Collider other)
{
// 例:タグで分岐して、別々のコマンドを各ポートに
if (other.CompareTag("Left"))
{
// 1台目(portNames0)へ '1'
SerialController.Instance.SendLineToIndex(0, "1");
}
else if (other.CompareTag("Right"))
{
// 2台目(portNames1)へ '2'
SerialController.Instance.SendLineToIndex(1, "2");
}
else if (other.CompareTag("Both"))
{
// ブロードキャスト(両方へ同じコマンド)
SerialController.Instance.SendLine("0");
}
}
}
2) ポート名指定で送る(Inspectorで設定した実名で直指定)
code:sample02
using UnityEngine;
public class PlayerScript : MonoBehaviour
{
// 例:Inspectorの SerialController.portNames に "COM5","COM6" を入れている想定
void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Left"))
{
SerialController.Instance.SendLineTo("COM5", "1");
}
else if (other.CompareTag("Right"))
{
SerialController.Instance.SendLineTo("COM6", "2");
}
}
}
参考
【研究室セミナー】超スマート社会をつくるVRxIoTセミナ「実世界を仮想化するフィジカル・コンピューティング入門」【2018・2019】
旧)Unity マイコン間のシリアル通信(送受信)(※UniRXを利用)
#シリアル通信
https://gyazo.com/71c7de59f100448c29cdb7f29fbd171b
[Communication Robotics Lab. http://yamalab.com/