OpenXRとMeta Questではじめる!AR/MRアプリの現実世界との位置合わせ - ARアンカーの共有編 -

に公開

openxr-meta-shared-anchor

1. はじめに

前回の記事「OpenXRとMeta Questではじめる!AR/MRアプリの現実世界との位置合わせ - ARアンカー編 -」では、AR Foundation のアンカー機能を利用して、Meta Quest で現実世界の位置と仮想空間の位置を合わせる方法について解説しました。

今回は、その続編として、複数デバイス間でアンカーを共有するShared Anchor(共有アンカー) 機能について解説します。
この機能により、複数のユーザーが同じ仮想空間を共有し、同じ位置に配置されたオブジェクトを見ることができるようになります。

この記事では、以下のようなマルチプレイヤー対応のアンカー共有機能を実装します。
https://youtube.com/shorts/tOqE8UgqFVk?feature=share

なお、この記事は、前回の記事の続きとして書かれていますので、4. ARアンカー利用の実装までを完了していることを前提としています。

2. 共有アンカーとは

2.1. 共有アンカーの概念

前回の記事で解説した ARアンカーは、単一のデバイス内で現実世界の位置を記憶し、アプリを再起動しても同じ位置にオブジェクトを配置できる機能でした。

一方、Shared Anchor(共有アンカー) は、複数のデバイス間で同じアンカーを共有できる機能です。
これにより、複数のユーザーが同じ現実空間内で、同じ仮想オブジェクトを同じ位置に見ることができるようになります。

2.2. AR Foundation + OpenXR Meta での共有方法

AR Foundation + OpenXR Meta では、グループベース共有(Group-based Sharing) がサポートされています。

共有の仕組み

Group UUID を使用して、グループ内の全デバイスでアンカーを共有します。

データの流れ:

  1. デバイスAがアンカーを作成し、Group UUIDを設定
  2. デバイスAがアンカーを共有(Meta クラウドにアップロード)
  3. デバイスBが同じGroup UUIDでアンカーを読み込み(Meta クラウドからダウンロード)
  4. 両デバイスで同じ位置にアンカーが表示される

技術的特徴:

  • アンカーデータは Meta のクラウドサーバー経由 で共有
  • インターネット接続が必要
  • 共有されたアンカーは最後の共有から 30日間有効
  • Group UUIDが一致するデバイス間でのみアンカーを共有可能

2.3. グループIDの設定方法

共有アンカーは グループID(Group UUID) で管理され、同じグループIDを設定したデバイス間でのみアンカーを共有できます。

グループIDをデバイス間で共有する方法は、開発者が選択できます:

方法1: 静的生成 ← 本記事の実装方法

アプリに固定文字列を埋め込み、全デバイスで同じGUIDを生成する方法です。

仕組み:

  • 文字列(例:"YourProjectName-SharedAnchors-Group-001")をMD5ハッシュ化
  • ハッシュ値からGUIDを生成
  • 同じ文字列からは常に同じGUIDが生成される

メリット:

  • 実装がシンプル
  • デバイス間の通信が不要
  • Colocation Discovery APIやネットワークレイヤーの追加実装不要

デメリット:

  • 同じアプリを使う全ユーザーが同じグループになる
  • セッションごとにグループを分けることができない

方法2: 動的生成 + デバイス間送信

ホストデバイスがランダムにGroup UUIDを生成し、他のデバイスに送信する方法です。

共通特徴:

  • セッションごとに異なるグループを作成可能
  • ユーザーグループを柔軟に管理できる

送信方法の選択肢:

A. Colocation Discovery API(Bluetooth)

  • 同じ物理空間内のデバイスを自動検出
  • ローカルエリアでの使用に最適
  • 最大1024バイトのセッションデータを送信可能

B. アプリ管理のネットワーク(Photon、Netcode など)

  • インターネット経由で遠隔地のデバイスとも接続可能
  • カスタムマッチメイキングロジックを実装可能
  • より複雑なグループ管理が可能

本記事では、実装のシンプルさを優先して静的生成を採用します。

2.4. ユースケース

共有アンカーは、以下のようなシーンで活用できます:

  • マルチプレイヤーゲーム:複数人で同じゲームボードやオブジェクトを操作
  • 共同作業:複数人で同じ3Dモデルをレビュー・編集
  • ガイドツアー:ガイドが配置したマーカーを参加者全員が見られる
  • 教育・トレーニング:複数の生徒が同じ教材を共有

3. 共有アンカー利用の実装

前回の記事で作成した ARAnchorController と AnchorPlacer に、共有アンカー機能を追加していきます。

3.1. ARAnchorController に共有機能を追加

前回作成した ARAnchorController.cs に、共有アンカー用のメソッドを追加します。

以下のメソッドを ARAnchorController.cs の末尾(#endregion の前)に追加してください:

ARAnchorController.cs(共有機能の追加部分)
#region Shared Anchors
/// <summary>
/// 共有アンカー機能がサポートされているかチェック
/// </summary>
public bool SupportsSharedAnchors(ARAnchorManager manager)
{
    try
    {
        MetaOpenXRAnchorSubsystem metaAnchorSubsystem = (MetaOpenXRAnchorSubsystem)manager.subsystem;
        
        if (metaAnchorSubsystem != null)
        {
            return metaAnchorSubsystem.isSharedAnchorsSupported == Supported.Supported;
        }
        
        return false;
    }
    catch (Exception e)
    {
        Debug.LogWarning($"共有アンカーサポート確認中にエラーが発生しました: {e.Message}");
        return false;
    }
}

/// <summary>
/// 共有アンカー用のグループIDを設定します
/// </summary>
public bool SetSharedAnchorsGroupId(ARAnchorManager manager, SerializableGuid groupId)
{
    if (!SupportsSharedAnchors(manager))
    {
        Debug.LogWarning("共有アンカー機能がサポートされていないため、グループIDを設定できません");
        return false;
    }

    try
    {
        MetaOpenXRAnchorSubsystem metaAnchorSubsystem = (MetaOpenXRAnchorSubsystem)manager.subsystem;
        
        if (metaAnchorSubsystem != null)
        {
            metaAnchorSubsystem.sharedAnchorsGroupId = groupId;
            Debug.Log($"共有アンカーのグループIDを設定しました: {groupId}");
            return true;
        }
        
        Debug.LogWarning("MetaOpenXRAnchorSubsystemの取得に失敗しました");
        return false;
    }
    catch (Exception e)
    {
        Debug.LogError($"グループID設定中にエラーが発生しました: {e.Message}");
        return false;
    }
}

/// <summary>
/// アンカーを共有します
/// </summary>
public async UniTask<bool> ShareAnchorAsync(ARAnchorManager manager, ARAnchor anchor)
{
    if (!SupportsSharedAnchors(manager))
    {
        Debug.LogWarning("共有アンカー機能がサポートされていません");
        return false;
    }

    if (anchor == null)
    {
        Debug.LogWarning("共有するアンカーがnullです");
        return false;
    }

    try
    {
        XRResultStatus resultStatus = await manager.TryShareAnchorAsync(anchor);
        
        if (resultStatus.IsSuccess())
        {
            Debug.Log($"アンカーの共有に成功しました: {anchor.trackableId}");
            return true;
        }
        else
        {
            string errorInfo = GetDetailedErrorInfo(resultStatus);
            Debug.LogError($"アンカーの共有に失敗しました: {errorInfo}");
            return false;
        }
    }
    catch (Exception e)
    {
        Debug.LogError($"アンカー共有中にエラーが発生しました: {e.Message}");
        return false;
    }
}

/// <summary>
/// すべての共有アンカーをXRAnchorとして読み込みます
/// </summary>
private async UniTask<List<XRAnchor>> LoadAllSharedAnchorsAsync(ARAnchorManager manager, Action<ReadOnlyListSpan<XRAnchor>> onIncrementalResults = null)
{
    List<XRAnchor> loadedAnchors = new List<XRAnchor>();
    
    if (!SupportsSharedAnchors(manager))
    {
        Debug.LogWarning("共有アンカー機能がサポートされていません");
        return loadedAnchors;
    }

    try
    {
        bool success = await manager.TryLoadAllSharedAnchorsAsync(loadedAnchors, onIncrementalResults ?? OnIncrementalXRAnchorResults);

        if (success)
        {
            Debug.Log($"共有XRAnchor読み込み完了: {loadedAnchors.Count}個のXRAnchorを読み込みました");
        }
        else
        {
            Debug.LogWarning("共有XRAnchorの読み込みに失敗しました");
        }
        
        return loadedAnchors;
    }
    catch (Exception e)
    {
        Debug.LogError($"共有XRAnchor読み込み中にエラーが発生しました: {e.Message}");
        return loadedAnchors;
    }
}

/// <summary>
/// XRAnchorの段階的読み込み結果を処理します
/// </summary>
private void OnIncrementalXRAnchorResults(ReadOnlyListSpan<XRAnchor> sharedAnchors)
{
    foreach (XRAnchor sharedAnchor in sharedAnchors)
    {
        Debug.Log($"共有XRAnchorを段階的に読み込みました: {sharedAnchor.trackableId}");
    }
}

/// <summary>
/// 共有アンカーを読み込んでARAnchorとして取得します
/// </summary>
public async UniTask<List<ARAnchor>> LoadAndCreateSharedAnchorsAsync(ARAnchorManager manager, Action<ARAnchor> onAnchorCreated = null, float timeoutSeconds = 10.0f)
{
    List<XRAnchor> loadedXRAnchors = await LoadAllSharedAnchorsAsync(manager);
    
    if (loadedXRAnchors.Count == 0)
    {
        Debug.Log("読み込み可能な共有アンカーがありません");
        return new List<ARAnchor>();
    }

    List<ARAnchor> arAnchors = await WaitForSharedAnchorsAsARAnchorAsync(manager, loadedXRAnchors, onAnchorCreated, timeoutSeconds);
    
    return arAnchors;
}

/// <summary>
/// 読み込み済みのXRAnchorリストからtrackablesChangedイベントでARAnchorを取得します
/// </summary>
private async UniTask<List<ARAnchor>> WaitForSharedAnchorsAsARAnchorAsync(ARAnchorManager manager, List<XRAnchor> loadedXRAnchors, Action<ARAnchor> onAnchorCreated = null, float timeoutSeconds = 10.0f)
{
    List<ARAnchor> foundARAnchors = new List<ARAnchor>();
    HashSet<TrackableId> expectedTrackableIds = new HashSet<TrackableId>();

    if (loadedXRAnchors == null || loadedXRAnchors.Count == 0)
    {
        Debug.Log("待機対象のXRAnchorがありません");
        return foundARAnchors;
    }

    // 期待するTrackableIdを記録
    foreach (XRAnchor xrAnchor in loadedXRAnchors)
    {
        expectedTrackableIds.Add(xrAnchor.trackableId);
    }

    UniTaskCompletionSource<bool> completionSource = new UniTaskCompletionSource<bool>();
    bool isCompleted = false;

    // trackablesChangedイベントハンドラを設定
    void OnTrackablesChanged(ARTrackablesChangedEventArgs<ARAnchor> eventArgs)
    {
        foreach (ARAnchor addedAnchor in eventArgs.added)
        {
            if (expectedTrackableIds.Contains(addedAnchor.trackableId))
            {
                foundARAnchors.Add(addedAnchor);
                Debug.Log($"共有アンカーのARAnchorを検出: {addedAnchor.trackableId}");
                
                // 外部コールバック通知
                onAnchorCreated?.Invoke(addedAnchor);
                
                // すべてのアンカーが見つかったかチェック
                if (foundARAnchors.Count >= expectedTrackableIds.Count)
                {
                    if (!isCompleted)
                    {
                        isCompleted = true;
                        completionSource.TrySetResult(true);
                    }
                }
            }
        }
    }

    try
    {
        // trackablesChangedイベントを監視開始
        manager.trackablesChanged.AddListener(OnTrackablesChanged);

        Debug.Log($"共有アンカーのARAnchor変換を開始: {expectedTrackableIds.Count}個のアンカーを待機中");

        // タイムアウト付きでARAnchorの作成を待機
        CancellationTokenSource timeoutCancellation = new CancellationTokenSource();
        timeoutCancellation.CancelAfter((int)(timeoutSeconds * 1000));

        try
        {
            await completionSource.Task.AttachExternalCancellation(timeoutCancellation.Token);
            Debug.Log($"共有アンカー変換完了: {foundARAnchors.Count}/{expectedTrackableIds.Count}個のARAnchorを発見");
        }
        catch (OperationCanceledException)
        {
            Debug.LogWarning($"共有アンカー変換タイムアウト: {foundARAnchors.Count}/{expectedTrackableIds.Count}個のARAnchorを発見 (タイムアウト: {timeoutSeconds}秒)");
        }

        return foundARAnchors;
    }
    catch (Exception e)
    {
        Debug.LogError($"共有アンカー変換中にエラーが発生しました: {e.Message}");
        return foundARAnchors;
    }
    finally
    {
        // イベントリスナーを削除
        manager.trackablesChanged.RemoveListener(OnTrackablesChanged);
    }
}

#endregion

3.2. AnchorPlacer に共有アンカー機能を追加

次に、AnchorPlacer.cs に共有アンカー用の実装を追加します。
今回の実装では、左コントローラーで共有アンカー操作右コントローラーで個別アンカー操作と役割を分けています。

3.2.1. フィールドの追加

AnchorPlacer.cs のフィールド部分に、左コントローラー用の設定を追加します:

AnchorPlacer.cs(フィールド追加)
// 共有アンカーロード完了時のイベント
public static event Action<List<ARAnchor>> OnLoadSharedAnchorsCompleted;

[Header("Left Controller Input for Shared Anchors")]
[SerializeField] private InputActionReference _leftControllerPositionAction;
[SerializeField] private InputActionReference _leftTriggerButtonAction;
[SerializeField] private InputActionReference _leftPrimaryButtonAction;

[Header("Shared Anchor Settings")]
[SerializeField] private string _sharedAnchorGroupId = "YourProjectName-SharedAnchors-Group-001"; // ← 任意の文字列に変更してください

3.2.2. グループID生成メソッドの追加

静的にグループIDを生成するメソッドを追加します:

AnchorPlacer.cs(グループID生成)
/// <summary>
/// 指定された文字列からGUIDを生成
/// </summary>
private string GenerateGuidFromString(string input)
{
    using MD5 md5 = MD5.Create();
    byte[] inputBytes = Encoding.UTF8.GetBytes(input);
    byte[] hashBytes = md5.ComputeHash(inputBytes);
    Guid guid = new Guid(hashBytes);

    return guid.ToString();
}

/// <summary>
/// 共有アンカーのグループIDを設定
/// </summary>
private void SetupSharedAnchorGroup()
{
    try
    {
        // 固定グループIDを設定
        SerializableGuid groupId = new SerializableGuid(Guid.Parse(GenerateGuidFromString(_sharedAnchorGroupId)));
        bool success = _anchorController.SetSharedAnchorsGroupId(_anchorManager, groupId);
        
        if (success)
        {
            Debug.Log($"共有アンカーのグループIDを設定しました: {_sharedAnchorGroupId}");
        }
        else
        {
            Debug.LogWarning("共有アンカーのグループID設定に失敗しました");
        }
    }
    catch (Exception e)
    {
        Debug.LogError($"共有アンカーグループID設定エラー: {e.Message}");
    }
}

3.2.3. 初期化処理の追加

Start() メソッドに共有アンカーグループの初期化を追加します:

AnchorPlacer.cs(Start メソッドの修正)
private void Start()
{
    // コントローラーボタンのイベント設定
    SetupInputActions();

    // 共有アンカーグループの初期化
    SetupSharedAnchorGroup();
}

3.2.4. 左コントローラー入力の設定

左コントローラーのボタンイベントを追加します:

AnchorPlacer.cs(左コントローラー入力設定)
private void SetupInputActions()
{
    // Right Controller (個別アンカー)
    if (_rightTriggerButtonAction != null)
    {
        _rightTriggerButtonAction.action.performed += OnRightTriggerPressed;
    }
    
    if (_rightPrimaryButtonAction != null)
    {
        _rightPrimaryButtonAction.action.performed += OnRightPrimaryButtonPressed;
    }
    
    if (_rightSecondaryButtonAction != null)
    {
        _rightSecondaryButtonAction.action.performed += OnRightSecondaryButtonPressed;
    }
    
    // Left Controller (共有アンカー)
    if (_leftTriggerButtonAction != null)
    {
        _leftTriggerButtonAction.action.performed += OnLeftTriggerPressed;
    }
    
    if (_leftPrimaryButtonAction != null)
    {
        _leftPrimaryButtonAction.action.performed += OnLeftPrimaryButtonPressed;
    }
}

private void EnableInputActions()
{
    // Right Controller
    _rightControllerPositionAction?.action.Enable();
    _rightTriggerButtonAction?.action.Enable();
    _rightPrimaryButtonAction?.action.Enable();
    _rightSecondaryButtonAction?.action.Enable();
    
    // Left Controller
    _leftControllerPositionAction?.action.Enable();
    _leftTriggerButtonAction?.action.Enable();
    _leftPrimaryButtonAction?.action.Enable();
}

private void DisableInputActions()
{
    // Right Controller
    _rightControllerPositionAction?.action.Disable();
    _rightTriggerButtonAction?.action.Disable();
    _rightPrimaryButtonAction?.action.Disable();
    _rightSecondaryButtonAction?.action.Disable();
    
    // Left Controller
    _leftControllerPositionAction?.action.Disable();
    _leftTriggerButtonAction?.action.Disable();
    _leftPrimaryButtonAction?.action.Disable();
}

private void OnLeftTriggerPressed(InputAction.CallbackContext context)
{
    PlaceSharedAnchorAtControllerPosition().Forget();
}

private void OnLeftPrimaryButtonPressed(InputAction.CallbackContext context)
{
    LoadSharedAnchors().Forget();
}

3.2.5. 共有アンカー配置・読み込みメソッドの追加

共有アンカーを配置・読み込むメソッドを追加します:

AnchorPlacer.cs(共有アンカー操作)
/// <summary>
/// コントローラーの現在位置に共有アンカーを配置
/// </summary>
private async UniTaskVoid PlaceSharedAnchorAtControllerPosition()
{
    // コントローラーの位置を取得(回転はWorld座標系で設定)
    Vector3 controllerPosition = GetLeftControllerPosition();
    Vector3 anchorPosition = controllerPosition + _anchorOffset;
    Quaternion anchorRotation = Quaternion.identity; // World座標系の回転
    
    Pose anchorPose = new Pose(anchorPosition, anchorRotation);
    
    await PlaceSharedAnchorAsync(anchorPose);
}

/// <summary>
/// 左コントローラーの位置を取得
/// </summary>
private Vector3 GetLeftControllerPosition()
{
    if (_leftControllerPositionAction != null && _leftControllerPositionAction.action.enabled)
    {
        return _leftControllerPositionAction.action.ReadValue<Vector3>();
    }
    
    Debug.LogWarning("左コントローラー位置が取得できません。");
    return Vector3.zero;
}

/// <summary>
/// 共有アンカーを配置
/// </summary>
private async UniTask PlaceSharedAnchorAsync(Pose pose)
{
    try
    {
        Debug.Log($"共有アンカー配置開始: {pose.position}");
        
        // ARAnchorControllerを使用してアンカーを作成
        ARAnchor anchor = await _anchorController.CreateAnchorAsync(_anchorManager, pose);
        
        if (anchor != null)
        {
            Debug.Log($"アンカーが正常に配置されました: {anchor.trackableId} at {pose.position}");
            
            // アンカーを共有
            bool shareSuccess = await _anchorController.ShareAnchorAsync(_anchorManager, anchor);
            if (shareSuccess)
            {
                Debug.Log($"アンカーを共有しました: {anchor.trackableId}");
            }
            else
            {
                Debug.LogWarning("アンカーの共有に失敗しました");
            }
        }
        else
        {
            Debug.LogWarning("共有アンカーの作成に失敗しました");
        }
    }
    catch (Exception e)
    {
        Debug.LogError($"共有アンカー配置中にエラーが発生しました: {e.Message}");
        Debug.LogError($"スタックトレース: {e.StackTrace}");
    }
}

/// <summary>
/// 共有アンカーを読み込み
/// </summary>
private async UniTaskVoid LoadSharedAnchors()
{
    try
    {
        Debug.Log("共有アンカーの読み込みを開始...");
        
        // 共有アンカーを読み込んでARAnchorとして取得
        List<ARAnchor> sharedAnchors = await _anchorController.LoadAndCreateSharedAnchorsAsync(_anchorManager, OnSharedAnchorCreated);
        
        Debug.Log($"共有アンカーロード完了: {sharedAnchors.Count}個のアンカーをロードしました");
        
        if (sharedAnchors.Count == 0)
        {
            Debug.Log("読み込み可能な共有アンカーがありませんでした");
        }
        else
        {
            OnLoadSharedAnchorsCompleted?.Invoke(sharedAnchors);
        }
    }
    catch (Exception e)
    {
        Debug.LogError($"共有アンカーロード中にエラーが発生しました: {e.Message}");
    }
}

/// <summary>
/// 共有アンカーが作成された際のコールバック
/// </summary>
private void OnSharedAnchorCreated(ARAnchor anchor)
{
    Debug.Log($"共有アンカーが作成されました: {anchor.trackableId} at {anchor.transform.position}");
    // 必要に応じてアンカー位置にオブジェクトを配置するなどの処理を追加
}

3.3. コントローラーの入力アクション設定

前回の記事で作成した Input Action に、左コントローラー用のアクションを追加します。

左コントローラー用アクションマップの追加

LeftController Action Map に以下を追加してください:

Action Maps Actions Action Properties Binding Properties
LeftController TriggerButtonAction Action Type : Button Path : <XRController>{LeftHand}/{TriggerButton}
PrimaryButtonAction Action Type : Button Path : <XRController>{LeftHand}/{PrimaryButton}
DevicePosition Action Type : Value
Control Type : Vector3
Path : <XRController>{LeftHand}/devicePosition

操作方法:

  • 左トリガー:現在の左コントローラー位置に共有アンカーを配置・共有
  • 左Xボタン(Primary Button):グループ内の共有アンカーを読み込み

3.4. シーンの設定

前回作成したシーンに、共有アンカー用の設定を追加します。

AnchorPlacer コンポーネントに以下の設定を追加:

  • Left Controller Position Action:LeftController > DevicePosition を設定
  • Left Trigger Button Action:LeftController > TriggerButtonAction を設定
  • Left Primary Button Action:LeftController > PrimaryButtonAction を設定
  • Shared Anchor Group Id:任意の識別文字列(例:"YourProjectName-SharedAnchors-Group-001")

3.5. 操作のまとめ

実装完了後の操作方法は以下の通りです:

コントローラー ボタン 機能
右コントローラー トリガー 個別アンカーを配置(ローカル保存)
Aボタン 個別アンカーを読み込み(PlayerPrefsから)
Bボタン 全てのアンカーを削除
左コントローラー トリガー 共有アンカーを配置・共有
Xボタン グループ内の共有アンカーを読み込み

4. まとめ

今回は、AR Foundation の共有アンカー機能を利用して、Meta Quest で複数デバイス間でアンカーを共有する方法について解説しました。

今後の発展:Photon Fusion2 との組み合わせ

今回の記事で紹介した共有アンカー機能は、複数デバイス間で「位置を共有する」基盤となります。

この基盤の上に Photon Fusion2 などのネットワークエンジンを組み合わせることで、より高度なマルチプレイヤー体験を実現できます。

記事冒頭のデモ動画で紹介したプロトタイプでは、共有アンカーと Photon Fusion2 を組み合わせて、複数人で同じオブジェクトを掴んで操作できる簡単な実装を行っています。

https://youtube.com/shorts/tOqE8UgqFVk?feature=share

実装例のリポジトリ:
👉 https://github.com/b0bmat0ca/OpenXR_Meta_Multiplay_Prototype

このリポジトリには、以下の機能が含まれています:

  • 共有アンカーによる複数デバイス間の位置合わせ
  • Photon Fusion2 によるネットワーク同期
  • 複数人で同じオブジェクトを掴んで移動させる実装
  • 手の位置や状態のリアルタイム同期

共有アンカーで「空間を共有」し、Photon Fusion2 で「動きを共有」することで、協調型のMR体験を構築できます。

エンジニア絶賛募集中!

MESONではUnityエンジニアを絶賛募集中です! XR、空間コンピューティングのプロジェクトに関わってみたい! 開発したい! という方はぜひご応募ください!

MESONのメンバーページからご応募いただくか、TwitterのDMなどでご連絡ください。

書いた人

ボブ

高野 剛

Unixシステムのインフラ構築・運用を経験後、ECサイトを中心としたWebアプリ開発、プロジェクトマネージメントに従事する。
ミニオン好きが高じて、USJに通い続ける中、XRアトラクションに魅了される。
自分が感動したことを他の人にも体験してもらいたいという思いから、転職を決意し、XRの学校での1年間の学びを経てMESONへ入社。

X

MESONテックブログ

Discussion