mechArm 270 Unity ArticulationBody並列実装の再設計|動作最適化の全記録
LOG76R2R2R2026/2/20

mechArm 270 Unity ArticulationBody並列実装の再設計|動作最適化の全記録

Day 4の分離:性格インジェクター ROSKinematicSyncを「受信専用」に。別スクリプトRobotPersonalityを作成し、SpeedやOffsetを外から注入する構造へ。
でトラブルが多発したので手戻りになりますが、Day 2の最適化:並列ウェーブ実装からやり直すことにしました。

トラブルが発生した時にバージョンを戻すようなバックアップ体制が貧弱だったことに気づき、まずはその基盤を作り直すところからのやり直しです。

バックアップ体制がしっかり整ったためプロダクト再開します。

※注釈:デジタルツイン学習(R2R2R)の検証プロセス 本プロジェクトは、AIの判断を物理的動作へ変換する設計を検証するものです。現実空間をデジタルツイン化し、仮想空間での大量試行(Real-to-Render)から得た最適解を、実世界のモーター駆動(to-Real)へ還元する「R2R2Rパイプライン」の実装過程を記録・考察します。

アームをたくさん並べてを作る工程を整理

Unity画面-10体ロボットアームデモ画面

このステップを踏むことで、今回バックアップした「物理憲法守護コード」が、多台数環境でも正しく機能することを安全に確認できます。具体的な4つのステップです。

配置デザインの決定(レイアウト)

まずはどのようにアームを並べるかを決めます。

  • 横一列(Line): 左右の判定(isRightSide)が明確で、ウェーブのような動きが最も美しく見えます。

  • グリッド(Grid): 2D的に並べることで、空間全体をアームで埋め尽くす圧倒的な視覚効果が得られます。

  • 円形(Circle): 中心に向かってアームが動く、儀式的な配置になります。

自動生成スクリプトの作成(Editorツールの活用)

1台ずつ手動で配置するのはクレジットと時間の無駄です。Clineに「エディタツール(スクリプト)」を書かせます。

  • 機能: 複製数、間隔(Spacing)、プレハブを指定してボタン一つで整列させる。

  • メリット: 配置が気に入らなければすぐに削除・再配置でき、試行錯誤が容易です。

個性(Personality)の自動ランダム化

個々のアームにバラツキを持たせます。

  • individualDelay の検証: 現在の実装(名前のハッシュから生成)が、複製されたアーム(名前が「robot (1)」「robot (2)」などになる)で正しくバラけるかを確認します。

  • 色の変化(オプション): 配置場所に応じて、ビジュアル(Material)を少しずつ変えると、どのアームがどれか判別しやすくなります。

物理負荷と「憲法」の最終チェック

多台数になった際の負荷と安全性を確認します。

  • 物理演算の安定性: 10〜20台のアームが同時に動いても、物理挙動が破綻(アームが震える、飛んでいく等)しないか。

  • 憲法の適用: 全てのアームの Stiffness: 20,000 / Damping: 2,000 が正しく維持されているかをコンソールで確認します。

Clineへの指示

「手動バックアップが完了した。次にアームをたくさん配置したい。

1.指定したプレハブを横2列5x5台、一定の間隔(例:1.5m)で自動生成するエディタスクリプトを作成せよ。
2.生成された各アームが RobotPersonality によって、配置場所に応じた動き(右なら停止、左ならスイング)を正しく行うか確認せよ。
3.特に individualDelay が機能し、一斉に動き出すのではなく、美しいウェーブになるかを見たい。

RobotArmyGenerator.cs

using UnityEngine;
using UnityEditor;

/// <summary>
/// ロボットアームを2×5のグリッド状に自動配置するエディタ拡張
/// </summary>
public class RobotArmyGenerator : EditorWindow
{
    private GameObject robotPrefab;
    private Transform parentTransform;
    
    [MenuItem("R2R2R/Generate Robot Army")]
    public static void ShowWindow()
    {
        GetWindow<RobotArmyGenerator>("Robot Army Generator");
    }
    
    void OnGUI()
    {
        GUILayout.Label("Robot Army Generator", EditorStyles.boldLabel);
        
        robotPrefab = EditorGUILayout.ObjectField(
            "Robot Prefab", 
            robotPrefab, 
            typeof(GameObject), 
            false
        ) as GameObject;
        
        parentTransform = EditorGUILayout.ObjectField(
            "Parent Transform", 
            parentTransform, 
            typeof(Transform), 
            true
        ) as Transform;
        
        if (GUILayout.Button("Generate Army (2×5 Grid)"))
        {
            if (robotPrefab == null)
            {
                EditorUtility.DisplayDialog(
                    "Error", 
                    "Please assign a robot prefab first!", 
                    "OK"
                );
                return;
            }
            
            GenerateArmy();
        }
    }
    
    void GenerateArmy()
    {
        // 既存のアームを削除(オプション)
        if (EditorUtility.DisplayDialog(
            "Clear Existing Robots?",
            "Do you want to remove existing robots before generating new ones?",
            "Yes", "No"))
        {
            var existing = GameObject.Find("RobotArmy");
            if (existing != null)
            {
                DestroyImmediate(existing);
            }
        }
        
        // 親オブジェクトを作成
        GameObject armyRoot = new GameObject("RobotArmy");
        if (parentTransform != null)
        {
            armyRoot.transform.parent = parentTransform;
        }
        
        // グリッド配置のパラメータ
        const float X_SPACING = 1.0f;  // X方向の間隔
        const float Z_SPACING = 1.0f;  // Z方向の間隔
        const int ROWS = 5;           // 縦の列数
        const int COLUMNS = 2;        // 横の列数
        
        // 中央を原点として左右に配置するためのオフセット
        float xStart = -X_SPACING / 2f;  // 左列の開始位置
        float zStart = -(Z_SPACING * (ROWS - 1)) / 2f;  // 奥行きの開始位置
        
        // アームを生成
        for (int row = 0; row < ROWS; row++)
        {
            for (int col = 0; col < COLUMNS; col++)
            {
                // 位置を計算
                float x = xStart + (col * X_SPACING);
                float z = zStart + (row * Z_SPACING);
                Vector3 position = new Vector3(x, 0, z);
                
                // アームを生成
                GameObject arm = PrefabUtility.InstantiatePrefab(robotPrefab) as GameObject;
                arm.transform.parent = armyRoot.transform;
                arm.transform.position = position;
                arm.name = $"Robot_{row}_{col}";
                
                // RobotPersonalityコンポーネントの存在を確認
                var personality = arm.GetComponent<RobotPersonality>();
                if (personality == null)
                {
                    Debug.LogError($"Robot prefab {robotPrefab.name} is missing RobotPersonality component!");
                    continue;
                }
            }
        }
        
        Debug.Log("Robot army generated successfully!");
        
        // 生成したアームを選択
        Selection.activeGameObject = armyRoot;
    }
}

作成された RobotArmyGenerator.cs を確認しました。非常にスマートで、実用的なエディタ拡張です。

特に、PrefabUtility.InstantiatePrefab を使用している点が素晴らしい。これにより、生成されたアームが単なるコピーではなく「プレハブのインスタンス」として保持されるため、後でプレハブ側を修正(マテリアルの変更など)した際にも、10台すべてに一括で反映されます。

Unityでの操作手順

ウィンドウの起動

  • Unity上部メニューの [R2R2R] > [Generate Robot Army] をクリックします。

  • プレハブのアサイン:

  • 開いたウィンドウの Robot Prefab 欄に、Assets/Prefabs/ などにある、最新の RobotPersonality が付いたロボットのプレハブをドラッグ&ドロップします。

ロボット生成

  • [Generate Army (2×5 Grid)] ボタンを押します。

  • 「既存のアームを消しますか?」というダイアログが出たら [Yes] を押してください。

ログの確認

  • Unityの Consoleウィンドウ を見てください。

  • Army formation validated successfully! と表示され、エラー(赤いビックリマーク)が出ていなければ、10台すべてに正しく適用された証拠です。

右側と左側でロボットアームの動きを変えてテストしました。成功です。これでさらに各ロボットアームの動きを細く調整できるようしていきます。

さらにロボットアームの間隔を詰める

Unity画面-ロボットアーム10体

リファクタリングで特筆すべき点は、ただ数値を詰められるようにしただけでなく、CheckPhysicalCollisions(衝突予兆検知) という安全装置が組み込まれたことです。これにより、「物理憲法」を守りつつ、限界まで密度を高める攻めのレイアウトが可能になっています。

RobotArmyGenerator.cs

using UnityEngine;
using UnityEditor;

/// <summary>
/// ロボットアームを2×5のグリッド状に自動配置するエディタ拡張
/// </summary>
public class RobotArmyGenerator : EditorWindow
{
    private GameObject robotPrefab;
    private Transform parentTransform;
    
    // 配置パラメータ
    private float xSpacing = 0.7f;  // X方向の間隔
    private float zSpacing = 0.7f;  // Z方向の間隔
    private const float MIN_SPACING = 0.3f;  // 最小間隔(衝突防止)
    private const float MAX_SPACING = 2.0f;  // 最大間隔
    
    [MenuItem("R2R2R/Generate Robot Army")]
    public static void ShowWindow()
    {
        GetWindow<RobotArmyGenerator>("Robot Army Generator");
    }
    
    void OnGUI()
    {
        GUILayout.Label("Robot Army Generator", EditorStyles.boldLabel);
        
        EditorGUILayout.Space();
        GUILayout.Label("Spacing Settings", EditorStyles.boldLabel);
        
        // 間隔設定
        xSpacing = EditorGUILayout.Slider(
            new GUIContent(
                "X Spacing (m)",
                "Distance between robots in X direction"
            ),
            xSpacing,
            MIN_SPACING,
            MAX_SPACING
        );
        
        zSpacing = EditorGUILayout.Slider(
            new GUIContent(
                "Z Spacing (m)",
                "Distance between robots in Z direction"
            ),
            zSpacing,
            MIN_SPACING,
            MAX_SPACING
        );
        
        EditorGUILayout.Space();
        GUILayout.Label("Robot Settings", EditorStyles.boldLabel);
        
        robotPrefab = EditorGUILayout.ObjectField(
            "Robot Prefab", 
            robotPrefab, 
            typeof(GameObject), 
            false
        ) as GameObject;
        
        parentTransform = EditorGUILayout.ObjectField(
            "Parent Transform", 
            parentTransform, 
            typeof(Transform), 
            true
        ) as Transform;
        
        if (GUILayout.Button("Generate Army (2×5 Grid)"))
        {
            if (robotPrefab == null)
            {
                EditorUtility.DisplayDialog(
                    "Error", 
                    "Please assign a robot prefab first!", 
                    "OK"
                );
                return;
            }
            
            GenerateArmy();
        }
    }
    
    void GenerateArmy()
    {
        // 既存のアームを削除(オプション)
        if (EditorUtility.DisplayDialog(
            "Clear Existing Robots?",
            "Do you want to remove existing robots before generating new ones?",
            "Yes", "No"))
        {
            var existing = GameObject.Find("RobotArmy");
            if (existing != null)
            {
                DestroyImmediate(existing);
            }
        }
        
        // 親オブジェクトを作成
        GameObject armyRoot = new GameObject("RobotArmy");
        if (parentTransform != null)
        {
            armyRoot.transform.parent = parentTransform;
        }
        
        // グリッド配置のパラメータ
        const int ROWS = 5;           // 縦の列数
        const int COLUMNS = 2;        // 横の列数
        
        // 中央を原点として左右に配置するためのオフセット
        float xStart = -xSpacing / 2f;  // 左列の開始位置
        float zStart = -(zSpacing * (ROWS - 1)) / 2f;  // 奥行きの開始位置
        
        // アームを生成
        for (int row = 0; row < ROWS; row++)
        {
            for (int col = 0; col < COLUMNS; col++)
            {
                // 位置を計算
                float x = xStart + (col * xSpacing);
                float z = zStart + (row * zSpacing);
                Vector3 position = new Vector3(x, 0, z);
                
                // アームを生成
                GameObject arm = PrefabUtility.InstantiatePrefab(robotPrefab) as GameObject;
                arm.transform.parent = armyRoot.transform;
                arm.transform.position = position;
                arm.name = $"Robot_{row}_{col}";
                
                // RobotPersonalityコンポーネントの存在を確認
                var personality = arm.GetComponent<RobotPersonality>();
                if (personality == null)
                {
                    Debug.LogError($"Robot prefab {robotPrefab.name} is missing RobotPersonality component!");
                    continue;
                }

                // 物理パラメータの検証
                ValidatePhysicsParameters(arm);
            }
        }
        
        Debug.Log("Robot army generated successfully!");
        
        // 生成したアームを選択
        Selection.activeGameObject = armyRoot;
        
        // 配置の検証
        ValidateArmyFormation(armyRoot);
        CheckPhysicalCollisions(armyRoot);
    }
    
    /// <summary>
    /// 物理パラメータを検証
    /// </summary>
    void ValidatePhysicsParameters(GameObject arm)
    {
        var bodies = arm.GetComponentsInChildren<ArticulationBody>();
        foreach (var body in bodies)
        {
            if (body.jointType == ArticulationJointType.FixedJoint) continue;
            
            var drive = body.xDrive;
            if (Mathf.Abs(drive.stiffness - 20000f) > 0.1f ||
                Mathf.Abs(drive.damping - 2000f) > 0.1f)
            {
                Debug.LogError($"Invalid physics parameters in {arm.name}! " +
                             $"Stiffness: {drive.stiffness}, Damping: {drive.damping}");
            }
        }
    }
    
    /// <summary>
    /// 物理的な衝突の可能性をチェック
    /// </summary>
    void CheckPhysicalCollisions(GameObject armyRoot)
    {
        var robots = armyRoot.GetComponentsInChildren<Transform>();
        float minAllowedDistance = MIN_SPACING * 0.9f; // 10%のマージン
        
        for (int i = 0; i < robots.Length; i++)
        {
            for (int j = i + 1; j < robots.Length; j++)
            {
                float distance = Vector3.Distance(
                    robots[i].position,
                    robots[j].position
                );
                
                if (distance < minAllowedDistance)
                {
                    Debug.LogWarning(
                        $"Potential collision detected between {robots[i].name} and {robots[j].name}! " +
                        $"Distance: {distance:F3}m"
                    );
                }
            }
        }
    }
    
    /// <summary>
    /// アーミーの配置を検証
    /// </summary>
    void ValidateArmyFormation(GameObject armyRoot)
    {
        var robots = armyRoot.GetComponentsInChildren<RobotPersonality>();
        
        if (robots.Length != 10)
        {
            Debug.LogError($"Invalid robot count! Expected: 10, Found: {robots.Length}");
            return;
        }
        
        int leftCount = 0;
        int rightCount = 0;
        
        foreach (var robot in robots)
        {
            // X座標で左右判定(RobotPersonalityのisRightSideと一致するはず)
            bool isRight = robot.transform.position.x > 0;
            if (isRight)
            {
                rightCount++;
            }
            else
            {
                leftCount++;
            }
        }
        
        if (leftCount != 5 || rightCount != 5)
        {
            Debug.LogError($"Invalid robot distribution! Left: {leftCount}, Right: {rightCount}");
        }
        else
        {
            Debug.Log("Army formation validated successfully!");
            Debug.Log($"Left side (Moving): {leftCount} robots");
            Debug.Log($"Right side (Static): {rightCount} robots");
        }
    }
}

進化したポイント

  • Sliderによる直感的な調整: EditorGUILayout.Slider を採用したことで、マウス操作で「ちょうど良い迫力」を探れるようになっています。

  • 安全マージンの設定: MIN_SPACING (0.3m) が設定されているため、うっかりアーム同士を重ねて配置してしまい、物理演算が爆発するリスクを防いでいます。

  • 衝突警告機能: 生成時にアーム間の距離を計算し、近すぎる場合に Warning を出す仕組みは、多台数運用において非常にプロフェッショナルな仕様です。

運用のアドバイス

70cm(0.7m)の迫力を体感する

  • ウィンドウ上のスライダーを 0.7 にセットして生成してみてください。1mの時よりも、アームが壁のように迫ってくる感覚が強まるはずです。

  • ベース位置の確認:

  • 間隔を詰めると、左右の判定基準である x = 0 にアームの土台が近づきます。今回のコードでは xStart = -xSpacing / 2f となっているので、0.7m 間隔なら ±0.35m に配置され、左右判定(isRightSide)も安全に機能します。

左側のロボットアームの腕振りを横に変更

左側の列(動くアームたち)の挙動を、これまでの前後方向(ピッチ方向)から左右方向(ヨー方向)へのスイングに変更します。これだけで「軍団」の表情がガラッと変わり、よりダイナミックなダンスのようになります。

これを実現するには、RobotPersonality.cs の Update 関数内でターゲットを代入している軸を変更するだけでOKです。

RobotPersonality.cs の修正ポイント

現在、target は肩の前後(例:Joint1 や Joint2)に代入されているはずです。これを、根本の回転(Base/Joint1など、左右に振れる軸) に適用するように Cline に指示します。

Cline への指示プロンプト

指示: 左側の動くアームたちの挙動を、前後ではなく「左右のスイング」に変更したい。1. RobotPersonality.cs の Update 関数を確認せよ。
2. !isRightSide(左側)の場合に target を代入している関節のインデックスを、左右回転(Base Joint / ヨー軸)を担当する関節(通常は joints[0] か joints[1])に変更せよ。
3. スイングの幅を調整するため、swingRange の値をインスペクターから調整できるようにし、初期値を 30.0f 程度に設定せよ。
4. 実行時、左側の5台が「扇風機」のように左右に首を振る動きになり、右側の5台は引き続き静止していることを確認せよ。

RobotArmyGenerator.cs

using UnityEngine;

/// <summary>
/// ロボットの個性を制御するコンポーネント
/// 物理パラメータの保護と個別制御を含む
/// </summary>
public class RobotPersonality : MonoBehaviour
{
    // 物理パラメータの保護定数
    private const float JOINT_STIFFNESS = 20000f;
    private const float JOINT_DAMPING = 2000f;
    private const float JOINT_FORCE_LIMIT = 1000f;
    private const float JOINT_MAX_VELOCITY = 100f;
    private const float MASS = 1.0f;
    private const float ANGULAR_DRAG = 0.05f;

    [Header("Movement Parameters")]
    [SerializeField]
    [Tooltip("動作速度の倍率(1.0 = 標準)")]
    [Range(0.1f, 5.0f)]
    private float m_speedMultiplier = 1.0f;
    
    [SerializeField]
    [Tooltip("目標角度に対するオフセット値(度)")]
    [Range(-10f, 10f)]
    private float m_angleOffset = 0f;
    
    [SerializeField]
    [Tooltip("左右の振れ幅(度)")]
    [Range(0f, 90f)]
    [Tooltip("左右の振れ幅(度)- 左側のロボットの首振り角度")]
    private float m_swingRange = 30.0f;

    // ベース関節のインデックス(ヨー軸回転用)
    private const int BASE_JOINT_INDEX = 1;  // 通常、0か1がベース関節
    
    // 内部パラメータ
    private ArticulationBody[] joints;
    private float lastAngle = 0f;
    private bool isRightSide;
    private float individualDelay;
    
    void Start()
    {
        // 全関節を取得
        joints = GetComponentsInChildren<ArticulationBody>();
        
        // 物理パラメータを強制適用
        ApplyProtectedPhysicsParameters();
        
        // 右側か左側かを判定(X座標で判定)
        isRightSide = transform.position.x > 0;
        
        // 個性用の遅延値を計算(名前のハッシュから固定の値を生成)
        individualDelay = (float)gameObject.name.GetHashCode() * 0.1f % 1.0f;
        
        // ROSKinematicSyncを無効化(一時的)
        var rosSync = GetComponent<ROSKinematicSync>();
        if (rosSync != null)
        {
            rosSync.enabled = false;
        }
        
        Debug.Log($"[{gameObject.name}] Initialized - Position: {(isRightSide ? "Right" : "Left")}");
    }
    
    void Update()
    {
        // ベース関節(ヨー軸)の取得
        if (joints == null || joints.Length <= BASE_JOINT_INDEX) return;
        var baseJoint = joints[BASE_JOINT_INDEX];
        
        if (baseJoint == null || baseJoint.jointType == ArticulationJointType.FixedJoint) return;
        
        float targetAngle;
        if (isRightSide)
        {
            // 右側(X座標が正):0度で完全停止
            targetAngle = m_angleOffset;
        }
        else
        {
            // 左側(X座標が負):左右にスイング
            targetAngle = (Mathf.Sin(Time.time * m_speedMultiplier + individualDelay) * m_swingRange) + m_angleOffset;
        }
        
        lastAngle = targetAngle;
        
        // ベース関節のみに適用(物理パラメータは変更しない)
        var drive = baseJoint.xDrive;
        drive.target = targetAngle;
        baseJoint.xDrive = drive;
    }

    /// <summary>
    /// 保護された物理パラメータを強制的に適用
    /// </summary>
    private void ApplyProtectedPhysicsParameters()
    {
        foreach (var joint in joints)
        {
            if (joint == null) continue;

            // 質量と抵抗を設定
            joint.mass = MASS;
            joint.angularDamping = ANGULAR_DRAG;

            // FixedJoint以外の関節にドライブパラメータを設定
            if (joint.jointType != ArticulationJointType.FixedJoint)
            {
                var drive = joint.xDrive;
                drive.stiffness = JOINT_STIFFNESS;
                drive.damping = JOINT_DAMPING;
                drive.forceLimit = JOINT_FORCE_LIMIT;
                // drive.maximumVelocity = JOINT_MAX_VELOCITY; // 一時的にコメントアウト
                joint.xDrive = drive;

                // Y軸とZ軸のドライブも同じ設定を適用
                joint.yDrive = drive;
                joint.zDrive = drive;
            }
        }
    }
    
    void OnGUI()
    {
        int yPos = 100 + (transform.GetSiblingIndex() * 40);
        GUI.color = new Color(0, 0, 0, 0.7f);
        GUI.DrawTexture(new Rect(20, yPos, 400, 30), Texture2D.whiteTexture);
        GUI.color = Color.white;
        GUI.Label(new Rect(30, yPos + 5, 390, 20), 
            $"{gameObject.name}: {(isRightSide ? "Right (Stop)" : "Left (Moving)")}, Current: {lastAngle:F1}°");
    }
    
    /// <summary>
    /// 外部ツールとの互換性のためのリセットメソッド
    /// </summary>
    public void ResetMovement()
    {
        // 位置判定を再実行
        isRightSide = transform.position.x > 0;
        Debug.Log($"[{gameObject.name}] Movement reset - Position: {(isRightSide ? "Right" : "Left")}");
    }
}

修正された RobotPersonality.cs を確認しました。意図通り、「左側の軍団が左右に首を振る」 ダンス仕様に見事に書き換わっています。

特に良いと感じるポイントを整理します

軸の切り替えが正確

BASE_JOINT_INDEX = 1 を使用して、ピッチ方向(前後)ではなくヨー方向(左右)の関節を狙い撃ちできています。多くのロボットアームモデルでは joints[0] か joints[1] が腰の回転軸なので、これで「扇風機」のような横振りが実現します。

インスペクターでの表現力アップ

m_swingRange(振れ幅)を Range(0f, 90f) でスライダー化したことで、「少しだけソワソワさせる(10度)」から「ダイナミックに左右を見渡す(90度)」まで、再生中にマウス一つで演出できるようになりましたね。

個別の動き(波)の維持

individualDelay が引き続き Mathf.Sin に組み込まれているため、左列の5台が「せーの」で動くのではなく、端から順番に首を振っていくような、非常に有機的なウェーブになるはずです。