【VRChat】VRでも日本語でチャットできるアプリを作る
はじめに
以前からVRChatというVRSNSで遊んでいるのですが、一つ不便に感じていることがあります。
それはVRモードだと日本語でチャットができないということです。声が出せる環境ならいいのですが夜とかやはり周りを気にするような場面では日本語でチャットできた方がいいよな...と思いました。
そう思ったのが今年の9月末くらいのことで、この機会にVR開発の勉強もかねて作成することにしました。
OSCを使ったチャット送信
VRChatには標準でOSCを用いてチャットを送信できる機能があります。
/chatbox/input s b n
チャットボックスにテキストを入力します。b
が True の場合、キーボードをバイパスしてs
のテキストをすぐに送信します。b
が False の場合、キーボードを開いて、提供されたテキストを入力します。
ということで初めにPythonでOSCを送信している先駆者様の記事を参考にチャットを送信してみます。
python-osc
をインストールして以下のプログラムを実行してみます。VRC側でOSCを有効にしてから実行します。
import argparse
import random
import time
from pythonosc import udp_client
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--ip", default="192.168.1.29",
help="The ip of the OSC server")
parser.add_argument("--port", type=int, default=9000,
help="The port the OSC server is listening on")
args = parser.parse_args()
client = udp_client.SimpleUDPClient(args.ip, args.port)
client.send_message("/chatbox/input", ("こんにちは", True))
time.sleep(1)
無事送信できました。
ここから本編
OSCでチャットを送信できることが分かったので、SteamVRのオーバーレイアプリケーションとして実装していきます。UIをVRCプレイ中にVR画面上に表示するにはこの選択肢しかないと思います。
制作にあたりこちらの記事を大変参考にさせていただきました。ありがとうございます。
というのもリファレンスがまあまあ不親切で、説明を読んでも分からなかったり、関数名や変数名で判断する部分が多かったりしたので非常に助かりました。
リファレンス&wiki↓
準備
今回はUnity2022.3.22f1で制作しています。
- uOSC
OSCをUnityで扱うためuOSCというライブラリを使用させていただきました。UnityPackageをインポートしておきます。
- SteamVR Plugin
SteamVRでの開発のために必要です。Asset Storeから入手しインポートしておきます。
-
Initialize XR on Startup
Project SettingsのXR Plug-in ManagementにあるInitialize XR on Startupのチェックを外しておきます。
-
Application Type
同様にOpenVRのAoolication TypeをOverlayに変更しておきます。
GameObject作成
システムオブジェクトの作成
ヒエラルキーにあるオブジェクトをすべて削除し、新たに空のオブジェクトを2つ作成します。
それぞれOverlayInputSystem
、OverlayActionSystem
という名前に変更します。
カメラとRenderTextureの作成
次にヒエラルキーにカメラを追加します。
Assetsに新たにRenderTextures
フォルダを作成します。
RenderTextures
フォルダ直下にRenderTextureを作成し、名前をInputTexture
に変更します。
今回はInputTexture
のサイズを(800, 562)に設定します。
カメラのTarget TextureにInputTexture
を設定しておきます。
Canvasの作成
ヒエラルキーにCanvasを追加し、InputCanvas
という名前に変更します。
Render ModeをScreen Space-Camera
に設定し、Render Cameraに先ほど作成したカメラを設定します。また、Plane Distanceを10に設定しておきます。
UIの作成
-
Font Assetの導入
そのままだとUIで日本語を表示できないので、新たに日本語フォントを導入します。
今回はMochiy Pop Oneというフォントを使用させていただきます。こちらのリンクを参考にFont Assetを作成しておきます。
-
InputFieldの作成
InputCanvasの中にInputFieldを作成します。RextTransformを次のように設定します。
Input Field SettingsでFontAssetを指定し、見やすいサイズになるようPoint Sizeを調整します。今回の場合以下のようになりました。
やや文字が下側に配置されているので、InputFieldの子にあるTextオブジェクトのAlignmentを次のように指定します。
-
送信ボタン作成
テキストを送信するボタンを作成します。
InputCanvasに新しくButtonを作成し、SendButton
に名前を変更します。
RextTransformを以下のように設定します。
テキストを"送信"に変更してサイズを調整しておきます。
-
一文字消去ボタン作成
テキストを一文字消去するボタンを作成します。
先ほどのSendButtonを複製し、DeleteButton
に名前を変更します。RectTransFormを次のように設定します。
テキストを"一文字消去"に変更してサイズを調整しておきます。 -
全消去ボタン作成
テキストを全消去するボタンを作成します。DeleteButtonを複製し、
AllDeleteButton
に名前を変更します。
テキストを"全消去"に変更してサイズを調整しておきます。
RectTransformを次のように設定します。
-
カーソルUIの作成
カーソル画像を作成します。今回はICOOON MONOさんの方位磁石アイコンを使用させていただきます。
ダウンロードしたらImport New Assetからインポートします。
Texture TypeをDefaultからSpriteに変更します。
右下のApplyを押します。もし真っ黒な場合はAdvancedのAlpha is Transparencyにチェックが入っているか確認してください。
InputCanvasにImageを追加し、
Cursor
に名前を変更します。このときカーソルは最前面に表示させたいため、InputCanvasの一番下の子に設定します。Source Imageに先ほど作成したSpriteを設定します。
サイズを調整します。今回は
(40,40)
に変更しました。 -
×ボタンの作成
キーボードを消去するボタンを作成します。SendButtonボタンを複製し、
HideButton
に名前を変更します。
RectTransformを次のように設定します。
わかりやすいように色を赤色に変更します。ButtonのNormal Colorを赤色に設定します。
テキストを×に変更し、サイズを調整します。やや下側によるのでRectTransformのTopを調整しました。
-
テキストボタンの作成
ひらがなのボタンを手作業で作るのは面倒なので、Prefabを作成してプログラムから作成することにします。テキストボタン用の親オブジェクトを作成します。InputCanvasのCursorの一個上に空のオブジェクトを作成し、
TextButtonParent
に名前を変更します。RextTransformを次のように設定します。
新しくTextButtonParentにボタンを作成し、
TextButton
に名前を変更します。RectTransformを次のように設定します。
Textにフォントと文字サイズを指定し、テキストの内容は空にしておきます。
新しくAssetsに
Prefab
というフォルダを作成し、TextButtonをドラッグ&ドロップしてPrefabにします。ヒエラルキー上のTextButtonは削除しておきます。UIをすべて作成するとCanvasは以下のようになります。
プログラムファイルの作成
AssetsにScripts
というフォルダを新しく作成し、次の3つのプログラムファイルを作成します。
- OverlayVRUtil.cs
必要な機能をここにまとめておきます。 - OverlayInputSystem.cs
オーバーレイ関係の管理をするプログラムです。 - OverlayActionSystem.cs
コントローラーの入力系を管理するプログラムです。
OverlayVRUtil.cs
詳細は適宜説明します。
using System;
using System.Collections.Generic;
using System.Runtime;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using Valve.VR;
namespace OverlayVRUtil
{
public static class OverlaySystem
{
public static void InitOpenVR()
{
if(OpenVR.System != null) return;
var err = EVRInitError.None;
OpenVR.Init(ref err, EVRApplicationType.VRApplication_Overlay);
if (err != EVRInitError.None)
{
throw new Exception("OpenVRの初期化に失敗しました: " + err);
}
}
public static void ShutdownOpenVR()
{
if (OpenVR.System != null)
{
OpenVR.Shutdown();
}
}
}
public static class Overlay
{
public static ulong CreateOverlay(string key, string name)
{
var handle = OpenVR.k_ulOverlayHandleInvalid;
var err = OpenVR.Overlay.CreateOverlay(key, name, ref handle);
EVRErrThrowException(err, "オーバーレイの作成に失敗しました");
return handle;
}
public static void DestroyOverlay(ulong handle)
{
if(handle != OpenVR.k_ulOverlayHandleInvalid)
{
var err = OpenVR.Overlay.DestroyOverlay(handle);
EVRErrThrowException(err, "オーバーレイの破棄に失敗しました");
}
}
private static void EVRErrThrowException(EVROverlayError error, string message)
{
if(error != EVROverlayError.None)
{
throw new Exception($"{message} : {error}");
}
}
public static void FlipOverlayVertical(ulong handle)
{
var bounds = new VRTextureBounds_t
{
uMin = 0,
uMax = 1,
vMin = 1,
vMax = 0
};
var err = OpenVR.Overlay.SetOverlayTextureBounds(handle, ref bounds);
EVRErrThrowException(err, "テクスチャの設定に失敗しました");
}
public static void SetOverlaySize(ulong handle, float size)
{
var err = OpenVR.Overlay.SetOverlayWidthInMeters(handle, size);
EVRErrThrowException(err, "オーバーレイのサイズ設定に失敗しました");
}
public static void HideOverlay(ulong handle)
{
var err = OpenVR.Overlay.HideOverlay(handle);
EVRErrThrowException(err, "オーバーレイの表示設定に失敗しました");
}
public static void ShowOverlay(ulong handle)
{
var err = OpenVR.Overlay.ShowOverlay(handle);
EVRErrThrowException(err, "オーバーレイの表示設定に失敗しました");
}
public static void SetOverlayRenderTexture(ulong handle, RenderTexture rendertexture)
{
if(!rendertexture.IsCreated()) return;
var nativeTexturePtr = rendertexture.GetNativeTexturePtr();
var texture = new Texture_t
{
eColorSpace = EColorSpace.Auto,
eType = ETextureType.DirectX,
handle = nativeTexturePtr
};
var err = OpenVR.Overlay.SetOverlayTexture(handle, ref texture);
EVRErrThrowException(err, "テクスチャの描画に失敗しました");
}
public static Vector2 GetOverlayIntersectionForController(
ulong overlayHandle, ETrackedControllerRole trackHand, int angle = 45)
{
var hitPoint = new Vector2(0f, 0f);
var controllerTransform = Overlay.GetControllerTransform(trackHand);
var direction = (controllerTransform.rot * Quaternion.AngleAxis(angle, Vector3.right)) * Vector3.forward;
var overlayParams = new VROverlayIntersectionParams_t
{
vSource = new HmdVector3_t
{
v0 = controllerTransform.pos.x,
v1 = controllerTransform.pos.y ,
v2 = -controllerTransform.pos.z
},
vDirection = new HmdVector3_t
{
v0 = direction.x,
v1 = direction.y,
v2 = -direction.z
},
eOrigin = ETrackingUniverseOrigin.TrackingUniverseStanding
};
VROverlayIntersectionResults_t overlayResults = default;
var hit = OpenVR.Overlay.ComputeOverlayIntersection(overlayHandle, ref overlayParams, ref overlayResults);
if(hit)
{
hitPoint.x = (overlayResults.vUVs.v0-0.5f);
hitPoint.y = (0.5f-overlayResults.vUVs.v1);
}
return hitPoint;
}
public static SteamVR_Utils.RigidTransform GetControllerTransform(ETrackedControllerRole trackHand)
{
//default Transform
var pos = new Vector3(0f, 0f, 0f);
var rot = Quaternion.Euler(0,0,0);
var defaultTransform = new SteamVR_Utils.RigidTransform(pos, rot);
var controllerIndex = OpenVR.System.GetTrackedDeviceIndexForControllerRole(trackHand);
if(controllerIndex == OpenVR.k_unTrackedDeviceIndexInvalid) return defaultTransform;
var poses = new TrackedDevicePose_t[OpenVR.k_unMaxTrackedDeviceCount];
OpenVR.System.GetDeviceToAbsoluteTrackingPose(ETrackingUniverseOrigin.TrackingUniverseStanding, 0, poses);
if(!poses[controllerIndex].bPoseIsValid) return defaultTransform;
else return new SteamVR_Utils.RigidTransform(poses[controllerIndex].mDeviceToAbsoluteTracking);
}
public static void SetTransformAbsolute(ulong handle, Vector3 pos, Quaternion rot)
{
var rigidTransform = new SteamVR_Utils.RigidTransform(pos, rot);
var matrix = rigidTransform.ToHmdMatrix34();
var err = OpenVR.Overlay.SetOverlayTransformAbsolute(
handle, ETrackingUniverseOrigin.TrackingUniverseStanding, ref matrix);
EVRErrThrowException(err, "オーバーレイの位置設定に失敗しました");
}
public static Button GetButtonByPosition(EventSystem eventSystem, GraphicRaycaster graphicRaycaster, Vector2 cursorPosition)
{
var pointerEventData = new PointerEventData(eventSystem);
pointerEventData.position = cursorPosition;
var raycastResultList = new List<RaycastResult>();
graphicRaycaster.Raycast(pointerEventData, raycastResultList);
var raycastResult = raycastResultList.Find(element => element.gameObject.GetComponent<Button>());
if(raycastResult.gameObject == null) return null;
else return raycastResult.gameObject.GetComponent<Button>();
}
}
public static class InputAction
{
public static void SetActionManifest(string path)
{
var err = OpenVR.Input.SetActionManifestPath(path);
EVRInputErrThrowException(err, "Action Manifestパスの指定に失敗しました");
}
public static void EVRInputErrThrowException(EVRInputError error, string message)
{
if(error != EVRInputError.None)
{
throw new Exception($"{message} : + {error}");
}
}
public static ulong GetActionSetHandlePath(string path)
{
ulong actionSetHandle = 0;
var err = OpenVR.Input.GetActionSetHandle(path, ref actionSetHandle);
EVRInputErrThrowException(err, "アクションセットの取得に失敗しました");
return actionSetHandle;
}
public static ulong GetActionHandlePath(string path)
{
ulong actionHandle = 0;
var err = OpenVR.Input.GetActionHandle(path, ref actionHandle);
EVRInputErrThrowException(err, "アクションの取得に失敗しました");
return actionHandle;
}
public static InputDigitalActionData_t GetDigitalActionData(ulong actionHandle)
{
var result = new InputDigitalActionData_t();
var digitalActionSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf(typeof(InputDigitalActionData_t));
var err = OpenVR.Input.GetDigitalActionData(actionHandle, ref result, digitalActionSize, OpenVR.k_ulInvalidInputValueHandle);
EVRInputErrThrowException(err, "アクションデータの取得に失敗しました");
return result;
}
}
}
OverlayInputSystem.cs
オーバーレイ管理側のプログラムの概要です。
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Valve.VR;
using OverlayVRUtil;
using TMPro;
using uOSC;
public class OverlayInputSystem : MonoBehaviour
{
Vector2 windowSize = new Vector2(800, 562);
[SerializeField] GameObject cursorObject;
[SerializeField] GameObject textButtonPrefab;
[SerializeField] GameObject parent;
[SerializeField] TMP_InputField inputField;
[SerializeField] uOscClient client;
[Range(0, 2f)] public float size = 0.5f;
[Range(-2f, 2f)] public float x;
[Range(-2f, 2f)] public float y;
[Range(-2f, 2f)] public float z;
[Range(0, 360)] public int rotationX;
[Range(0, 360)] public int rotationY;
[Range(0, 360)] public int rotationZ;
[SerializeField] ETrackedControllerRole trackHand = ETrackedControllerRole.RightHand;
[SerializeField] RenderTexture inputTexture;
private bool isOverlayVisible = false;
private bool isFinishSend = true;
private ulong overlayHandle = OpenVR.k_ulOverlayHandleInvalid;
string[] texts = {
"あ", "い", "う", "え", "お",
"か", "き", "く", "け", "こ",
"さ", "し", "す", "せ", "そ",
"た", "ち", "つ", "て", "と",
"な", "に", "ぬ", "ね", "の",
"は", "ひ", "ふ", "へ", "ほ",
"ま", "み", "む", "め", "も",
"や", "、", "ゆ", "。", "よ",
"ら", "り", "る", "れ", "ろ",
"わ", "を", "ん", "!", "?"
};
void Start()
{
OverlayVRUtil.OverlaySystem.InitOpenVR();
overlayHandle = Overlay.CreateOverlay("VRInputJab1key", "VRInputJab1");
CreateTextButton();
Overlay.FlipOverlayVertical(overlayHandle);
Overlay.SetOverlaySize(overlayHandle, size);
SetOverlayRenderTexture();
Overlay.HideOverlay(overlayHandle);
}
// Update is called once per frame
void Update()
{
if(!isOverlayVisible) SetOverlayRenderTexture();
var hitPosition = Overlay.GetOverlayIntersectionForController(overlayHandle,trackHand);
cursorObject.transform.localPosition = hitPosition * windowSize;
Overlay.SetOverlayRenderTexture(overlayHandle, inputTexture);
}
private void SetOverlayRenderTexture()
{
var controllerTransform = Overlay.GetControllerTransform(trackHand);
var rot = controllerTransform.rot.eulerAngles;
var diff = new Vector3(x, y, z);
var rotation = Quaternion.Euler(rotationX, rot.y + rotationY, rotationZ);
Overlay.SetTransformAbsolute(overlayHandle, controllerTransform.pos + diff, rotation);
}
private void CreateTextButton()
{
for(int i = 0; i < texts.Length/5; i++)
{
for(int j = 0; j < 5; j++)
{
var textButton = Instantiate(textButtonPrefab) as GameObject;
textButton.transform.localPosition -= new Vector3(i*80, j*80, 0);
var buttonTextComponent = textButton.GetComponentInChildren<Text>();
buttonTextComponent.text = texts[5*i+j];
textButton.transform.SetParent(parent.transform, false);
textButton.GetComponent<Button>().onClick.AddListener(() => OnClickTextButton(texts[5*i+j]));
}
}
}
public void OnClickTextButton(string text)
{
inputField.text += text;
}
public void OnClickDeleteButton()
{
string fieldText = inputField.text;
inputField.text = FieldText.Substring(0, fieldText.Length-1);
}
public void OnClickAllDeleteButton()
{
inputField.text = "";
}
public void OnClickSendButton()
{
if(isFinishSend)
{
isFinishSend = false;
client.Send("/chatbox/input", inputField.text, true);
inputField.text = "";
isFinishSend = true;
}
}
public void ShowKeyboard()
{
if(!isOverlayVisible)
{
Overlay.ShowOverlay(overlayHandle);
isOverlayVisible = true;
}
}
public void HideKeyboard()
{
if(isOverlayVisible)
{
Overlay.HideOverlay(overlayHandle);
isOverlayVisible = false;
}
}
public bool GetOverlayVisible()
{
return isOverlayVisible;
}
public void OnOSCMessageReceived(Message message)
{
if(message.address == "/avatar/parameters/Chat") ShowKeyboard();
}
private void OnApplicationQuit()
{
Overlay.DestroyOverlay(overlayHandle);
}
private void Destroy()
{
OverlayVRUtil.OverlaySystem.ShutdownOpenVR();
}
}
前述のこちらの記事で実装した関数をいくつか流用しています。
各種パラメータ
Vector2 windowSize = new Vector2(800, 562);
[SerializeField] GameObject cursorObject;
[SerializeField] GameObject textButtonPrefab;
[SerializeField] GameObject parent;
[SerializeField] TMP_InputField inputField;
[SerializeField] uOscClient client;
[Range(0, 2f)] public float size = 0.5f;
[Range(-2f, 2f)] public float x;
[Range(-2f, 2f)] public float y;
[Range(-2f, 2f)] public float z;
[Range(0, 360)] public int rotationX;
[Range(0, 360)] public int rotationY;
[Range(0, 360)] public int rotationZ;
[SerializeField] ETrackedControllerRole trackHand = ETrackedControllerRole.RightHand;
[SerializeField] RenderTexture inputTexture;
private bool isOverlayVisible = false;
private bool isFinishSend = true;
private ulong overlayHandle = OpenVR.k_ulOverlayHandleInvalid;
string[] texts = {
"あ", "い", "う", "え", "お",
"か", "き", "く", "け", "こ",
"さ", "し", "す", "せ", "そ",
"た", "ち", "つ", "て", "と",
"な", "に", "ぬ", "ね", "の",
"は", "ひ", "ふ", "へ", "ほ",
"ま", "み", "む", "め", "も",
"や", "、", "ゆ", "。", "よ",
"ら", "り", "る", "れ", "ろ",
"わ", "を", "ん", "!", "?"
};
-
cursorObject
カーソルとなるImageです。 -
textButtonPrefab
テキストボタンのPrefabを設定します。 -
parent
テキストボタンを生成する親オブジェクトを設定します。 -
inputField
テキストを入力するInputFieldです。 -
client
テキストを送信するためuOSC Clientを設定します。 -
size, xyz, rotationXYZ
表示させるオーバーレイのサイズ、位置のオフセット、回転のオフセットをここで設定します。 -
trackHand
オーバーレイを表示するコントローラです。私は右利きなのでRightHandを指定しています。 -
inputTexture
RenderTextureです。CameraのTarget Textureで設定しているRenderTextureを設定します。 -
isOverlayVisible
オーバーレイが表示されているかのフラグです。 -
isFinishSend
テキストの重複送信を防ぐためのフラグです。 -
texts
作成するボタンのひらがなを配列に格納しています。
Start()
Start()
関数では初期化や設定を行っています。
void Start()
{
OverlayVRUtil.OverlaySystem.InitOpenVR();
overlayHandle = Overlay.CreateOverlay("VRInputJab1key", "VRInputJab1");
CreateTextButton();
Overlay.FlipOverlayVertical(overlayHandle);
Overlay.SetOverlaySize(overlayHandle, size);
SetOverlayRenderTexture();
Overlay.HideOverlay(overlayHandle);
}
各関数に関して下に記載します。参考記事と一緒なので軽く触れるだけにします。
-
InitOpenVR()
OpenVRの初期化を行っています。
public static class OverlaySystem
{
public static void InitOpenVR()
{
if(OpenVR.System != null) return;
var err = EVRInitError.None;
OpenVR.Init(ref err, EVRApplicationType.VRApplication_Overlay);
if (err != EVRInitError.None)
{
throw new Exception("OpenVRの初期化に失敗しました: " + err);
}
}
}
-
CreateOverlay("VRInputJakey", "VRInputJa")
オーバーレイの作成を行っています。
public static ulong CreateOverlay(string key, string name)
{
var handle = OpenVR.k_ulOverlayHandleInvalid;
var err = OpenVR.Overlay.CreateOverlay(key, name, ref handle);
EVRErrThrowException(err, "オーバーレイの作成に失敗しました");
return handle;
}
private static void EVRErrThrowException(EVROverlayError error, string message)
{
if(error != EVROverlayError.None)
{
throw new Exception(message + ":" + error);
//throw new Exception($"{message} : {error}");
}
}
-
CreateTextButton()
テキストボタンを作成しています。 -
FlipOverlayVertical(overlayHandle)
テクスチャの方向を反転させています。
public static void FlipOverlayVertical(ulong handle)
{
var bounds = new VRTextureBounds_t
{
uMin = 0,
uMax = 1,
vMin = 1,
vMax = 0
};
var err = OpenVR.Overlay.SetOverlayTextureBounds(handle, ref bounds);
EVRErrThrowException(err, "テクスチャの設定に失敗しました");
}
-
SetOverlaySize(overlayHandle, size)
オーバーレイのサイズを設定しています。
public static void SetOverlaySize(ulong handle, float size)
{
var err = OpenVR.Overlay.SetOverlayWidthInMeters(handle, size);
EVRErrThrowException(err, "オーバーレイのサイズ設定に失敗しました");
}
-
SetOverlayRenderTexture()
こちらの関数でオーバーレイをコントローラーに追従させています。詳しくは後述します。 -
HideOverlay(overlayHandle)
オーバーレイを非表示にしています。
public static void HideOverlay(ulong handle)
{
var err = OpenVR.Overlay.HideOverlay(handle);
EVRErrThrowException(err, "オーバーレイの表示に失敗しました");
}
Update()
Update()
関数の詳細を見ていきます。
void Update()
{
if(!isOverlayVisible) SetOverlayRenderTexture();
var hitPosition = Overlay.GetOverlayIntersectionForController(overlayHandle,trackHand);
cursorObject.transform.localPosition = hitPosition * windowSize;
Overlay.SetOverlayRenderTexture(overlayHandle, inputTexture);
}
-
if(!isOverlayVisible) SetOverlayRenderTexture()
オーバーレイが非表示になっている場合のみオーバーレイをコントローラーに追従させます。これにより入力中にオーバーレイが動かないようにし、同時にオーバーレイを表示させたときコントローラーの付近に出現するようにします。 -
GetOverlayIntersectionForController(overlayHandle,trackHand)
コントローラーの姿勢から現在オーバーレイのどの位置を指しているのかを返します。
public static Vector2 GetOverlayIntersectionForController(
ulong overlayHandle, ETrackedControllerRole trackHand, int angle = 45)
{
var hitPoint = new Vector2(0f, 0f);
var controllerTransform = Overlay.GetControllerTransform(trackHand);
var direction = (controllerTransform.rot * Quaternion.AngleAxis(angle, Vector3.right)) * Vector3.forward;
var overlayParams = new VROverlayIntersectionParams_t
{
vSource = new HmdVector3_t
{
v0 = controllerTransform.pos.x,
v1 = controllerTransform.pos.y ,
v2 = -controllerTransform.pos.z
},
vDirection = new HmdVector3_t
{
v0 = direction.x,
v1 = direction.y,
v2 = -direction.z
},
eOrigin = ETrackingUniverseOrigin.TrackingUniverseStanding
};
VROverlayIntersectionResults_t overlayResults = default;
var hit = OpenVR.Overlay.ComputeOverlayIntersection(overlayHandle, ref overlayParams, ref overlayResults);
if(hit)
{
hitPoint.x = (overlayResults.vUVs.v0-0.5f);
hitPoint.y = (0.5f-overlayResults.vUVs.v1);
}
return hitPoint;
}
public static SteamVR_Utils.RigidTransform GetControllerTransform(ETrackedControllerRole trackHand)
{
//default Transform
var pos = new Vector3(0f, 0f, 0f);
var rot = Quaternion.Euler(0,0,0);
var defaultTransform = new SteamVR_Utils.RigidTransform(pos, rot);
var controllerIndex = OpenVR.System.GetTrackedDeviceIndexForControllerRole(trackHand);
if(controllerIndex == OpenVR.k_unTrackedDeviceIndexInvalid) return defaultTransform;
var poses = new TrackedDevicePose_t[OpenVR.k_unMaxTrackedDeviceCount];
OpenVR.System.GetDeviceToAbsoluteTrackingPose(ETrackingUniverseOrigin.TrackingUniverseStanding, 0, poses);
if(!poses[controllerIndex].bPoseIsValid) return defaultTransform;
else return new SteamVR_Utils.RigidTransform(poses[controllerIndex].mDeviceToAbsoluteTracking);
}
少し長いですが概要としてはGetControllerTransform
関数でコントローラーの位置および姿勢をRigidTransform形で取得し、それをもとにオーバーレイとの交点を取得しています。
先にGetControllerTransform
関数について説明します。
public static SteamVR_Utils.RigidTransform GetControllerTransform(ETrackedControllerRole trackHand)
{
//default Transform
var pos = new Vector3(0f, 0f, 0f);
var rot = Quaternion.Euler(0,0,0);
var defaultTransform = new SteamVR_Utils.RigidTransform(pos, rot);
var controllerIndex = OpenVR.System.GetTrackedDeviceIndexForControllerRole(trackHand);
if(controllerIndex == OpenVR.k_unTrackedDeviceIndexInvalid) return defaultTransform;
var poses = new TrackedDevicePose_t[OpenVR.k_unMaxTrackedDeviceCount];
OpenVR.System.GetDeviceToAbsoluteTrackingPose(ETrackingUniverseOrigin.TrackingUniverseStanding, 0, poses);
if(!poses[controllerIndex].bPoseIsValid) return defaultTransform;
else return new SteamVR_Utils.RigidTransform(poses[controllerIndex].mDeviceToAbsoluteTracking);
}
- デフォルトトランスフォームを取得
//default Transform
var pos = new Vector3(0f, 0f, 0f);
var rot = Quaternion.Euler(0,0,0);
var defaultTransform = new SteamVR_Utils.RigidTransform(pos, rot);
- コントローラに割り当てられているインデックス番号を取得
var controllerIndex = OpenVR.System.GetTrackedDeviceIndexForControllerRole(trackHand);
- コントローラーが見つからなかったらデフォルトトランスフォームを返す
if(controllerIndex == OpenVR.k_unTrackedDeviceIndexInvalid) return defaultTransform;
- GetDeviceToAbsoluteTrackingPose関数を用いてコントローラーのトランスフォームを取得
var poses = new TrackedDevicePose_t[OpenVR.k_unMaxTrackedDeviceCount];
OpenVR.System.GetDeviceToAbsoluteTrackingPose(ETrackingUniverseOrigin.TrackingUniverseStanding, 0, poses);
- 取得できなかった場合はデフォルトトランスフォームを返す
if(!poses[controllerIndex].bPoseIsValid) return defaultTransform;
- 取得できた場合はRigitTransForm構造体として返す
else return new SteamVR_Utils.RigidTransform(poses[controllerIndex].mDeviceToAbsoluteTracking);
これによりコントローラーの位置および姿勢を取得できます。
次にGetOverlayIntersectionForController()
関数の説明です。
public static Vector2 GetOverlayIntersectionForController(
ulong overlayHandle, ETrackedControllerRole trackHand, int angle = 45)
{
var hitPoint = new Vector2(0f, 0f);
var controllerTransform = Overlay.GetControllerTransform(trackHand);
var direction = (controllerTransform.rot * Quaternion.AngleAxis(angle, Vector3.right)) * Vector3.forward;
var overlayParams = new VROverlayIntersectionParams_t
{
vSource = new HmdVector3_t
{
v0 = controllerTransform.pos.x,
v1 = controllerTransform.pos.y ,
v2 = -controllerTransform.pos.z
},
vDirection = new HmdVector3_t
{
v0 = direction.x,
v1 = direction.y,
v2 = -direction.z
},
eOrigin = ETrackingUniverseOrigin.TrackingUniverseStanding
};
VROverlayIntersectionResults_t overlayResults = default;
var hit = OpenVR.Overlay.ComputeOverlayIntersection(overlayHandle, ref overlayParams, ref overlayResults);
if(hit)
{
hitPoint.x = (overlayResults.vUVs.v0-0.5f);
hitPoint.y = (0.5f-overlayResults.vUVs.v1);
}
return hitPoint;
}
- hitpointの初期値を設定
var hitPoint = new Vector2(0f, 0f);
- コントローラーの位置および姿勢を取得
var controllerTransform = Overlay.GetControllerTransform(trackHand);
- コントローラーの指し示す方向を取得
var direction = (controllerTransform.rot * Quaternion.AngleAxis(angle, Vector3.right)) * Vector3.forward;
デフォルトだと若干傾いているので、x軸に45度回転させることで補正しています。
- インターセクション計算のパラメータ(VROverlayIntersectionParams_t構造体、VROverlayIntersectionResults_t構造体)を設定
var overlayParams = new VROverlayIntersectionParams_t
{
vSource = new HmdVector3_t
{
v0 = controllerTransform.pos.x,
v1 = controllerTransform.pos.y ,
v2 = -controllerTransform.pos.z
},
vDirection = new HmdVector3_t
{
v0 = direction.x,
v1 = direction.y,
v2 = -direction.z
},
eOrigin = ETrackingUniverseOrigin.TrackingUniverseStanding
};
VROverlayIntersectionResults_t overlayResults = default;
インターセクションを計算するためのパラメータ(位置、方向)を設定しています。UnityとOpenVRでz軸の正の方向が異なるため、値を負にしています。
- ComputeOverlayIntersection関数を用いてインターセクションを計算
var hit = OpenVR.Overlay.ComputeOverlayIntersection(overlayHandle, ref overlayParams, ref overlayResults);
- 位置を補正
if(hit)
{
hitPoint.x = (overlayResults.vUVs.v0-0.5f);
hitPoint.y = (0.5f-overlayResults.vUVs.v1);
}
Unity側のUIでは中心を(0,0)と設定したためここで補正しています。
- 位置を返す
return hitPoint;
これによりコントローラーが指し示す位置を取得できます。長くなりましたがUpdate()
関数の続きです。
-
cursorObject.transform.localPosition = hitPosition * windowSize;
取得したコントローラーが指し示す位置は0~1になっているためウィンドウサイズで拡大し、カーソル画像の位置を更新します。 -
SetOverlayRenderTexture(overlayHandle, inputTexture);
オーバーレイを更新します。これによりカーソル画像が更新された位置に見えるようになります。
public static void SetOverlayRenderTexture(ulong handle, RenderTexture rendertexture)
{
if(!rendertexture.IsCreated()) return;
var nativeTexturePtr = rendertexture.GetNativeTexturePtr();
var texture = new Texture_t
{
eColorSpace = EColorSpace.Auto,
eType = ETextureType.DirectX,
handle = nativeTexturePtr
};
var err = OpenVR.Overlay.SetOverlayTexture(handle, ref texture);
EVRErrThrowException(err, "テクスチャの描画に失敗しました");
}
SetOverlayRenderTexture()
SetOverlayRenderTexture()
関数でオーバーレイをコントローラーに追従させています。
private void SetOverlayRenderTexture()
{
var controllerTransform = Overlay.GetControllerTransform(trackHand);
var rot = controllerTransform.rot.eulerAngles;
var diff = new Vector3(x, y, z);
var rotation = Quaternion.Euler(rotationX, rot.y + rotationY, rotationZ);
Overlay.SetTransformAbsolute(overlayHandle, controllerTransform.pos + diff, rotation);
}
-
GetControllerTransform(trackHand)
コントローラーの位置および姿勢を取得しています。 -
rot = controllerTransform.rot.eulerAngles
コントローラーの姿勢をクォータニオンからオイラー角に変換します。 -
diff = new Vector3(x, y, z)
各軸の位置のオフセットからVector3を生成します。 -
rotation = Quaternion.Euler(rotationX, rot.y + rotationY, rotationZ)
オーバーレイの回転を設定します。コントローラーのy軸回転のみ使用し、他はユーザーで設定できるようにします。 -
Overlay.SetTransformAbsolute(overlayHandle, controllerTransform.pos + diff, rotation)
オーバーレイの位置および回転を適用しています。
public static void SetTransformAbsolute(ulong handle, Vector3 pos, Quaternion rot)
{
var rigidTransform = new SteamVR_Utils.RigidTransform(pos, rot);
var matrix = rigidTransform.ToHmdMatrix34();
var err = OpenVR.Overlay.SetOverlayTransformAbsolute(
handle, ETrackingUniverseOrigin.TrackingUniverseStanding, ref matrix);
EVRErrThrowException(err, "オーバーレイの位置設定に失敗しました");
}
CreateTextButton
private void CreateTextButton()
{
for(int i = 0; i < texts.Length/5; i++)
{
for(int j = 0; j < 5; j++)
{
string buttonText = texts[5*i+j];
var textButton = Instantiate(textButtonPrefab) as GameObject;
textButton.transform.localPosition -= new Vector3(i*80, j*80, 0);
var buttonTextComponent = textButton.GetComponentInChildren<TextMeshProUGUI>();
buttonTextComponent.text = buttonText;
textButton.transform.SetParent(parent.transform, false);
textButton.GetComponent<Button>().onClick.AddListener(() => OnClickTextButton(buttonText));
}
}
}
-
for()
位置を調整するため、5個ごとに分けて処理を行っています。 -
Instantiate
テキストボタンをプレハブから生成します。 -
localPosition -= new Vector3(i*80, j*80, 0)
位置を調整しています。Prefabの値から引き算することで実現しています。 -
GetComponentInChildren<TextMeshProUGUI>
Buttonの子にあるTextオブジェクトを取得します。 -
buttonTextComponent.text = buttonText
テキストを設定します。今思えば二次元配列にすれば良かったなぁという感じです。 -
SetParent(parent.transform, false)
ボタンを親オブジェクトの子に設定します。 -
GetComponent<Button>().onClick.AddListener(() => OnClickTextButton
ボタンが押された時のイベントを設定しています。
OnClickTextButton()
public void OnClickTextButton(string text)
{
inputField.text += text;
}
テキストボタンのテキストをInputFieldに追加するだけの関数です。
OnClickDeleteButton()
public void OnClickDeleteButton()
{
string fieldText = inputField.text;
inputField.text = fieldText.Substring(0, fieldText.Length-1);
}
InputFieldのテキストを一字削除しています。
OnClickAllDeleteButton()
public void OnClickAllDeleteButton()
{
inputField.text = "";
}
InputFieldの中身を空にしています。
OnClickSendButton()
public void OnClickSendButton()
{
if(isFinishSend)
{
isFinishSend = false;
client.Send("/chatbox/input", inputField.text, true);
inputField.text = "";
isFinishSend = true;
}
}
OSCを用いてチャットを送信しています。フラグを用いて重複送信しないようにしています。
ShowKeyboard()
オーバーレイを表示します。同時にinputFieldの内容を空にして、isOverlayVisible
フラグをtrueに設定しています。
public void ShowKeyboard()
{
if(!isOverlayVisible)
{
inputField.text = "";
Overlay.ShowOverlay(overlayHandle);
isOverlayVisible = true;
}
}
public static void ShowOverlay(ulong handle)
{
var err = OpenVR.Overlay.ShowOverlay(handle);
EVRErrThrowException(err, "オーバーレイの表示設定に失敗しました");
}
HideKeyboard()
オーバーレイを非表示にします。
public void HideKeyboard()
{
if(isOverlayVisible)
{
Overlay.HideOverlay(overlayHandle);
isOverlayVisible = false;
}
}
public static void HideOverlay(ulong handle)
{
var err = OpenVR.Overlay.HideOverlay(handle);
EVRErrThrowException(err, "オーバーレイの表示設定に失敗しました");
}
GetOverlayVisible()
isOverlayVisible
フラグを外部から取得できるようにしています。
public bool GetOverlayVisible()
{
return isOverlayVisible;
}
OnOSCMessageReceived()
OSCを傍受する関数です。今回はアバターパラメータからキーボードを表示したいので、受け取ったOSCアドレスが/avatar/parameters/Chat
だった場合、ShowKeyboard
メソッドを呼び出してオーバーレイを表示させます。
public void OnOSCMessageReceived(Message message)
{
if(message.address == "/avatar/parameters/Chat") ShowKeyboard();
}
OverlayInputSystem側の設定
OSCイベントの設定
OverlayInputSystemオブジェクトにuOSC Client, uOSC Serverをアタッチします。
ポートを次のように設定します。
OverlayInputSystem.csをOverlayInputSystemオブジェクトにアタッチします。
次のように設定します。(サイズ、位置、回転はあとで実行した際にお好みで調整してください。)
uOSC SereverのOn Data ReceivedイベントにOnOSCMessageReceived関数を指定します。
各Buttonイベントの設定
- SendButton
SendButtonのOnClickイベントにOnClickSendButton
を指定します。
- DeleteButton
DeleteButtonのOnClickイベントにOnClickDeleteButton
を指定します。
- AllDeleteButton
AllDeleteButtonのOnClickイベントにOnClickAllDeleteButton
を指定します。
- HideButton
HideButtonのOnClickイベントにHideKeyboard
を指定します。
確認
ここまで来たら一度再生ボタンを押して確認します。
SteamVRのオーバーレイビューアを起動します。
下の方にVRInputJab1があるので選択してオーバーレイを確認してください
ボタンが正常に作成されていればOKです。可能であれば/chatメッセージを送信してオーバーレイをVR上に表示してみてください。カーソルがコントローラーに追従して移動することが確認できるはずです。なおアクションの設定をしていないためこの段階ではまだボタンを押しても反応しません。
OverlayActionSystem.cs
コントローラによる入力管理側のプログラム概要です。
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Valve.VR;
using OverlayVRUtil;
using UnityEngine.EventSystems;
public class OverlayActionSystem : MonoBehaviour
{
[SerializeField] GameObject cursorObject;
[SerializeField] GraphicRaycaster graphicRaycaster;
[SerializeField] EventSystem eventSystem;
[SerializeField] OverlayInputSystem inputSystem;
Vector3 diff = new Vector3(400, 281, 0);
ulong actionSetHandle = 0;
ulong actionHandle = 0;
void Start()
{
OverlayVRUtil.OverlaySystem.InitOpenVR();
InputAction.SetActionManifest(Application.streamingAssetsPath + "/SteamVR/actions.json");
actionSetHandle = InputAction.GetActionSetHandlePath("/actions/RightTriggerInput");
actionHandle = InputAction.GetActionHandlePath($"/actions/RightTriggerInput/in/InputAction");
}
void Update()
{
var actionSetList = new VRActiveActionSet_t[]
{
new VRActiveActionSet_t()
{
ulActionSet = actionSetHandle,
ulRestrictedToDevice = OpenVR.k_ulInvalidInputValueHandle,
}
};
var activeActionSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf(typeof(VRActiveActionSet_t));
var err = OpenVR.Input.UpdateActionState(actionSetList, activeActionSize);
InputAction.EVRInputErrThrowException(err, "アクションの更新に失敗しました");
var result = InputAction.GetDigitalActionData(actionHandle);
if(result.bState && result.bChanged)
{
var cursorPosition = cursorObject.transform.localPosition + diff;
var button = Overlay.GetButtonByPosition(eventSystem, graphicRaycaster, cursorPosition);
if(button != null && inputSystem.GetOverlayVisible()) button.onClick.Invoke();
}
}
private void Destroy()
{
OverlayVRUtil.OverlaySystem.ShutdownOpenVR();
}
}
各種パラメータ
[SerializeField] GameObject cursorObject;
[SerializeField] GraphicRaycaster graphicRaycaster;
[SerializeField] EventSystem eventSystem;
[SerializeField] OverlayInputSystem inputSystem;
Vector3 bias = new Vector3(400, 281, 0);
ulong actionSetHandle = 0;
ulong actionHandle = 0;
-
cursorObject
カーソル画像のオブジェクトです。 -
graphicRaycaster
Buttonを取得するのに使用します。 -
eventSystem
同じくButtonを取得する際に使用します -
bias
カーソル画像の位置とcanvasの位置との差を埋めるためのオフセットを設定します。通常ではカーソル画像の中心をもとにボタンを取得してしまいます。カーソル画像の形状を考慮すると上部をもとに取得したほうが直感的です。そのため画面の高さの半分256にカーソル画像の高さの半分25を足した281を設定しています。
-
inputSystem
オーバーレイが表示されているかを取得するために使用します。
Start()
Start()
関数はInputSystem同様設定や初期化を行っています。こちらも参照記事とほとんど一緒なので軽く触れるだけにしておきます。
void Start()
{
OverlayVRUtil.OverlaySystem.InitOpenVR();
InputAction.SetActionManifest(Application.streamingAssetsPath + "/SteamVR/actions.json");
actionSetHandle = InputAction.GetActionSetHandlePath("/actions/RightTriggerInput");
actionHandle = InputAction.GetActionHandlePath($"/actions/RightTriggerInput/in/InputAction");
}
-
InitOpenVR()
OpenVRの初期化を行っています。 -
SetActionManifest()
アクションマニフェストを読み込みます。なお今回はRightTriggerInput
という名前のアクションセットを作成し、右手のTriggerにInputAction
というアクションを割り当てています。
public static class InputAction
{
public static void SetActionManifest(string path)
{
var err = OpenVR.Input.SetActionManifestPath(path);
EVRInputErrThrowException(err, "Action Manifestパスの指定に失敗しました");
}
public static void EVRInputErrThrowException(EVRInputError error, string message)
{
if(error != EVRInputError.None)
{
throw new Exception(message + ":" + error);
}
}
}
-
GetActionSetHandlePath()
アクションセットを読み込みます。
public static ulong GetActionSetHandlePath(string path)
{
ulong actionSetHandle = 0;
var err = OpenVR.Input.GetActionSetHandle(path, ref actionSetHandle);
EVRInputErrThrowException(err, "アクションセットの取得に失敗しました");
return actionSetHandle;
}
-
GetActionHandlePath()
アクションを読み込みます。
public static ulong GetActionHandlePath(string path)
{
ulong actionHandle = 0;
var err = OpenVR.Input.GetActionHandle(path, ref actionHandle);
EVRInputErrThrowException(err, "アクションの取得に失敗しました");
return actionHandle;
}
Update()
Update()
の詳細を見ていきます
void Update()
{
var actionSetList = new VRActiveActionSet_t[]
{
new VRActiveActionSet_t()
{
ulActionSet = actionSetHandle,
ulRestrictedToDevice = OpenVR.k_ulInvalidInputValueHandle,
}
};
var activeActionSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf(typeof(VRActiveActionSet_t));
var err = OpenVR.Input.UpdateActionState(actionSetList, activeActionSize);
InputAction.EVRInputErrThrowException(err, "アクションの更新に失敗しました");
var result = InputAction.GetDigitalActionData(actionHandle);
if(result.bState && result.bChanged)
{
var cursorPosition = cursorObject.transform.localPosition + bias;
var button = Overlay.GetButtonByPosition(eventSystem, graphicRaycaster, cursorPosition);
if(button != null && inputSystem.GetOverlayVisible()) button.onClick.Invoke();
}
}
-
actionSetList = new VRActiveActionSet_t[]
アクション状態の更新に必要なパラメータを設定しています。 -
activeActionSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf()
アクション状態の更新に必要なパラメータを取得しています。 -
err = OpenVR.Input.UpdateActionState()
ユーザーのアクション状態を更新します。 -
EVRInputErrThrowException()
ユーザーのアクション状態の更新ができていない場合例外をthrowします。 -
GetDigitalActionData()
ユーザーのアクション状態を取得します。詳細の説明は割愛します。
public static InputDigitalActionData_t GetDigitalActionData(ulong actionHandle)
{
var result = new InputDigitalActionData_t();
var digitalActionSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf(typeof(InputDigitalActionData_t));
var err = OpenVR.Input.GetDigitalActionData(actionHandle, ref result, digitalActionSize, OpenVR.k_ulInvalidInputValueHandle);
EVRInputErrThrowException(err, "アクションデータの取得に失敗しました");
return result;
}
-
if(result.bState && result.bChanged)
アクションが実行されていた場合trueになります。 -
cursorPosition = cursorObject.transform.localPosition + cursordiff
カーソル画像の位置をオフセット分を加算して取得しています。 -
GetButtonByPosition()
GraphicRaycasterを使用して押されたボタンのコンポーネントを取得します。
public static Button GetButtonByPosition(EventSystem eventSystem, GraphicRaycaster graphicRaycaster, Vector2 cursorPosition)
{
var pointerEventData = new PointerEventData(eventSystem);
pointerEventData.position = cursorPosition;
var raycastResultList = new List<RaycastResult>();
graphicRaycaster.Raycast(pointerEventData, raycastResultList);
var raycastResult = raycastResultList.Find(element => element.gameObject.GetComponent<Button>());
if(raycastResult.gameObject == null) return null;
else return raycastResult.gameObject.GetComponent<Button>();
}
-
if(button != null && inputSystem.GetOverlayVisible())
Buttonが取得できているかつオーバーレイが表示中の時trueになります。 -
button.onClick.Invoke()
ButtonのOnClickイベントを発生させます。
OverlayActionSystem側の設定
アクションの設定
SteamVRの設定を開いて、左下の方にある詳細設定を表示します。
開発者オプションの"入力バインドユーザーインターフェースのデバッグオプションを有効化"をオンにします。
UnityのWindow>SteamVR Inputを開きます。
ポップアップが出るので"No"を選択します。
次のように設定します。
RightTriggerInput
がGetActionSetHandlePath
で設定した名前、InputAction
がGetActionHandlePath
で設定した名前に対応しています。
actionSetHandle = InputAction.GetActionSetHandlePath("/actions/RightTriggerInput");
actionHandle = InputAction.GetActionHandlePath($"/actions/RightTriggerInput/in/InputAction");
左下のSave and Generateをクリックします。ロードが終了したらOpen Binding UIをクリックします。
"バインドの新規作成"をクリックして、設定画面を開きます。
+ボタンを押してinputactionをボタンとして設定し、チェックマークを押して確定します。私は右利きなので右のトリガーに設定しました。
"デフォルトバインドの置換"をクリックし、保存します。すると以下のようにアクションが登録されます。
パラメータの設定
OverlayActionSystemオブジェクトにOverlayActionSystem.csをアタッチし、以下のように設定します。
アバター側の設定
ここからはキーボードを表示するためにアバター側で設定を行います。Modular Avatarを使用していきます。
今回プログラムでは/avatar/parameters/Chat
メッセージを受信しています。アバターからメッセージを送信するには、Chat
という名前のパラメータを作成し、Expression Menuに設定する必要があります。
アバターの子に空のオブジェクトを作成し、Chat
という名前に変更します。
ChatオブジェクトにMA Menu Itemコンポーネントを追加し、次のように設定します。
ChatオブジェクトにMA Menu Installerコンポーネントを追加します。"メニューを選択"をクリックすると好きな階層に導入できます。何も選択しない場合は自動的にExpressions Menuの一番上の階層に導入されます。
アバターをアップロードします。
完成
Expressions MenuからChatを選択するとキーボードが出現し、送信を押すとチャットが送信できます。
(画面揺れまくってます。ごめんなさい。)
おわりに
思っていた以上に長くなってしまいました。ここまで読んでいただいた皆様ありがとうございます。
今回紹介したものは作り始めてから数週間程度のもので、現在はカタカナや漢字への変換を実装したりスマホ配列や独自配列を検討したりしています。できれば3月末までにはリリースしたいです。
今年に入って日本のVRChat人口が著しく増加したのはありがたいことですが、今回のチャットなどを含め完全な日本対応はまだまだ先のように感じています。私が作らずとも公式が実装してくれればありがたいのですが。
最後になりますが今回の記事について質問、意見、アドバイス等ございましたらご指摘、ご享受いただけると幸いです。
この記事は 一関高専Advent Calendar 2024 7日目の記事でした。お読みいただきありがとうございました。
Discussion