ハコブくんの自律走行がUnity病院シーンにもたらす動的ノイズ|HRI設計の検証
LOG64HRI2026/2/11

ハコブくんの自律走行がUnity病院シーンにもたらす動的ノイズ|HRI設計の検証

病院の器(ハコ)が完成し、次なるステップはそこに流れる「時間」と「営み」の再現です。今回は、単なる障害物としてではない、独自の意思(アルゴリズム)で動く病院スタッフの自動生成システムを実装しました。

※薬剤搬送ロボットハコブくんは技術検証用の仮想設定です。 実用化目的ではなく、制御・UIデザイン・HRI(人間とロボットの相互作用)の本質を探求するコンセプトとして定義しています。

スタッフを自動生成するスクリプト

Unity画面-スタッフの自動生成

これまでの HospitalBaseBuilder.cs とは別に、HospitalStaffSpawner.cs という名前で新しいスクリプトを作成し、空の GameObject(名前:StaffManagerなど)にアタッチして使用します。

HospitalStaffSpawner.cs

このスクリプトは、実行時に指定された人数のスタッフを廊下の空いているスペースにランダムに配置します。

C#

using UnityEngine;
using UnityEngine.AI;
using System.Collections.Generic;

public class HospitalStaffSpawner : MonoBehaviour
{
    [Header("Staff Settings")]
    [SerializeField] private int maleNurseCount = 4;   // 看護師(男):薄水色
    [SerializeField] private int femaleNurseCount = 6; // 看護師(女):薄ピンク
    [SerializeField] private int doctorCount = 3;      // 医師(男女):薄グレー

    [Header("Colors")]
    private Color maleNurseColor = new Color(0.7f, 0.9f, 1.0f);   // 薄い水色
    private Color femaleNurseColor = new Color(1.0f, 0.8f, 0.9f); // 薄いピンク
    private Color doctorColor = new Color(0.85f, 0.85f, 0.85f);  // 薄いグレー

    void Start()
    {
        // 病院の床のサイズに合わせて生成範囲を調整(適宜変更してください)
        SpawnStaffGroup("MaleNurse", maleNurseCount, maleNurseColor, 1.8f);
        SpawnStaffGroup("FemaleNurse", femaleNurseCount, femaleNurseColor, 1.6f);
        SpawnStaffGroup("Doctor", doctorCount, doctorColor, 1.75f);
    }

    void SpawnStaffGroup(string label, int count, Color color, float height)
    {
        for (int i = 0; i < count; i++)
        {
            // 1. スタッフの本体(カプセル)を生成
            GameObject staff = GameObject.CreatePrimitive(PrimitiveType.Capsule);
            staff.name = label + "_" + i;
            
            // 2. スケール調整(身長)
            staff.transform.localScale = new Vector3(0.5f, height / 2f, 0.5f);
            
            // 3. マテリアル設定(色)
            Renderer rend = staff.GetComponent<Renderer>();
            rend.material = new Material(Shader.Find("Standard"));
            rend.material.color = color;
            rend.material.SetFloat("_Glossiness", 0f); // テカリ防止

            // 4. ランダムな位置に配置(NavMesh上の有効な場所を探す)
            Vector3 randomPos = GetRandomNavMeshPosition();
            staff.transform.position = randomPos + Vector3.up * (height / 2f);

            // 5. 自律走行用のNavMeshAgentを追加
            NavMeshAgent agent = staff.AddComponent<NavMeshAgent>();
            agent.speed = 1.2f; // 歩行速度
            agent.acceleration = 2.0f;
            agent.radius = 0.3f;
            agent.avoidancePriority = Random.Range(30, 60); // ロボットとの優先度分け

            // 6. 回遊スクリプト(簡易版)を付与
            staff.AddComponent<StaffPatrol>();
        }
    }

    Vector3 GetRandomNavMeshPosition()
    {
        for (int i = 0; i < 30; i++)
        {
            Vector3 randomPoint = new Vector3(Random.Range(-40f, 40f), 0, Random.Range(-20f, 20f));
            NavMeshHit hit;
            if (NavMesh.SamplePosition(randomPoint, out hit, 2.0f, NavMesh.AllAreas))
            {
                return hit.position;
            }
        }
        return Vector3.zero;
    }
}

// スタッフが適当に歩き回るための簡易クラス
public class StaffPatrol : MonoBehaviour
{
    private NavMeshAgent agent;
    private float timer;

    void Start()
    {
        agent = GetComponent<NavMeshAgent>();
        SetNewDestination();
    }

    void Update()
    {
        timer += Time.deltaTime;
        if (timer > 5f && (agent.remainingDistance < 0.5f || !agent.hasPath))
        {
            SetNewDestination();
            timer = 0;
        }
    }

    void SetNewDestination()
    {
        Vector3 target = transform.position + new Vector3(Random.Range(-15f, 15f), 0, Random.Range(-15f, 15f));
        NavMeshHit hit;
        if (NavMesh.SamplePosition(target, out hit, 5.0f, NavMesh.AllAreas))
        {
            agent.SetDestination(hit.position);
        }
    }
}

構造と意図の分析

  • 色の再現: 指示通り、各職種に合わせた淡いトーンの色を Color 型で直接指定しています。

  • NavMeshとの連携: NavMesh.SamplePosition を使用することで、壁の中や個室のベッドの上にスタッフが埋まってしまうのを防ぎます。

  • 自律走行: NavMeshAgent を付与し、StaffPatrol スクリプトで一定時間ごとにランダムな目的地へ向かわせることで、静止画ではない「生きた病院」を再現しています。

  • 影・テカリ対策: 前回の教訓を活かし、マテリアル生成時に Glossiness を 0 にしてノイズ(シマシマ)の発生リスクを抑えています。

職種による「振る舞い」の定義

ただ歩き回るだけでは、それは「動く物体」に過ぎません。本格的なシミュレーションを目指し、職種ごとに異なる行動パラメータをコードに落とし込みました。

  • 看護師(Nurse): テキパキとフロアを駆け巡り、次のタスクへと素早く移動。

  • 医師(Doctor): 移動は少しゆったり。しかし、一度立ち止まると長く留まり、思索やカンファレンスを行う。

これを enum StaffRole によるステート管理で実装。目的地に到達した際の待機時間(waitTimer)を職種ごとに変動させることで、フロアに「急いでいる人」と「立ち止まっている人」のコントラストを生み出しました。

 NavMeshを利用した生存領域の確保

スタッフが壁に埋まったり、ベッドの上を歩いたりしてはリアリティが崩壊します。 NavMesh.SamplePosition を活用し、生成時および目的地設定時に「そこは人間が歩ける場所か?」を常に検証。病院という複雑な構造物の中で、知的な経路探索を実現しました。

見えない光の教訓を継承する

前回の「マテリアル乱立による描画破綻」の教訓を忘れず、スタッフの生成コードにもその哲学を反映しています。

  • テカリの抑制: _Glossiness を 0 に設定。不自然な反射による視覚的ノイズを排除。

  • 色の意味論: 薄水色、薄ピンク、グレー。病院らしい清潔感のあるパステルカラーを割り当て、ユーザーが直感的に職種を識別できるよう配慮。

ハコブくんへの「挑戦状」

このスタッフたちの導入により、搬送ロボット「ハコブくん」の難易度は飛躍的に上がりました。 常に一定の速度で動く機械とは異なり、「急に立ち止まる」「しばらく動かない」「予期せぬ速度で横切る」人間というノイズ。これこそが、ハコブくんが真に克服すべき「病院の日常」です。

さらに全員が常に歩き続けているよりも、「立ち止まってカルテを確認する」「数人で集まって打ち合わせをする」「持ち場へ急ぐ」といった動作を加えることで、より現場に近いシミュレーションになります。

これを実現するために、StaffPatrol クラスを大幅にアップグレードしまSU。各職種ごとに異なる「歩行速度」と「滞在時間」のパラメータを設定します。

修正版:HospitalStaffSpawner.cs 内の StaffPatrol クラス

以下のコードを、HospitalStaffSpawner.cs の後半にある StaffPatrol クラスと丸ごと差し替えます。

C#

public class StaffPatrol : MonoBehaviour
{
    public enum StaffRole { MaleNurse, FemaleNurse, Doctor }
    public StaffRole role;

    private NavMeshAgent agent;
    private float waitTimer;
    private bool isWaiting;

    // 職種ごとの設定値
    private float walkSpeed;
    private float minWaitTime;
    private float maxWaitTime;

    void Start()
    {
        agent = GetComponent<NavMeshAgent>();
        ApplyRoleSettings();
        SetNewDestination();
    }

    void ApplyRoleSettings()
    {
        switch (role)
        {
            case StaffRole.MaleNurse:
                walkSpeed = Random.Range(1.2f, 1.5f); // 標準的
                minWaitTime = 2f; maxWaitTime = 6f;   // 適度に立ち止まる
                break;
            case StaffRole.FemaleNurse:
                walkSpeed = Random.Range(1.1f, 1.4f); // 標準的
                minWaitTime = 1f; maxWaitTime = 4f;   // テキパキ動く
                break;
            case StaffRole.Doctor:
                walkSpeed = Random.Range(0.8f, 1.1f); // 少しゆっくり(考え事や回診)
                minWaitTime = 5f; maxWaitTime = 12f;  // 長めに立ち止まる
                break;
        }
        agent.speed = walkSpeed;
    }

    void Update()
    {
        if (isWaiting)
        {
            waitTimer -= Time.deltaTime;
            if (waitTimer <= 0)
            {
                isWaiting = false;
                SetNewDestination();
            }
        }
        else
        {
            // 目的地に到着したか、道が塞がっている場合
            if (!agent.pathPending && agent.remainingDistance < 0.5f)
            {
                StartWaiting();
            }
        }
    }

    void StartWaiting()
    {
        isWaiting = true;
        waitTimer = Random.Range(minWaitTime, maxWaitTime);
        agent.ResetPath(); // 立ち止まる
    }

    void SetNewDestination()
    {
        // 廊下や各部屋へランダムに移動
        Vector3 target = transform.position + new Vector3(Random.Range(-20f, 20f), 0, Random.Range(-20f, 20f));
        NavMeshHit hit;
        if (NavMesh.SamplePosition(target, out hit, 10.0f, NavMesh.AllAreas))
        {
            agent.SetDestination(hit.position);
        }
    }
}

※ HospitalStaffSpawner クラス内の SpawnStaffGroup メソッドにある staff.AddComponent<StaffPatrol>(); の直後に、以下の1行を追加して職種を教えてあげる必要があります。

C#

// SpawnStaffGroup メソッド内の修正箇所
var patrol = staff.AddComponent<StaffPatrol>();
if (label == "MaleNurse") patrol.role = StaffPatrol.StaffRole.MaleNurse;
else if (label == "FemaleNurse") patrol.role = StaffPatrol.StaffRole.FemaleNurse;
else if (label == "Doctor") patrol.role = StaffPatrol.StaffRole.Doctor;

今回の変更点の分析

  • 状態遷移(Idle/Walk)の導入: isWaiting フラグを使い、目的地に着いたら一定時間停止するロジックを加えました。これにより「業務中に立ち止まる」様子が再現されます。

  • 職種別の特性:

  • 医師: 移動速度を抑えめにし、一度立ち止まると長く留まるように設定しました。診察やカンファレンスのイメージです。

  • 看護師: 医師より速く動き、次の目的地へ次々と向かう機動力を設定しました。

  • NavMeshAgentの制御: agent.ResetPath() を呼ぶことで、待機中にエージェントがその場で足踏みしたり、微振動したりするのを防いでいます。

原因分析と学びのまとめ

  • 「役割(Role)」の実装: 単一のスクリプトに条件分岐(Switch文)を持たせることで、多様な個性を効率的に生成できた。

  • agent.ResetPath() の重要性: 待機時に経路をリセットしないと、NavMeshAgentが微細な振動を起こす「ジッタリング」が発生する。これを防ぐことで、静止時の美しさを確保した。

  • AIとの対話: ロジックを組む際、AIに「職種ごとの性格」を提案させることで、自分だけでは思いつかなかったパラメータの幅(医師の長い待機時間など)に気づくことができた。

生成AIに全てを行わせて原因追求しないと、プロセスがブラックボックス化してしまいます。 なぜ医師はゆっくり歩くのか?なぜ看護師はすぐ次へ向かうのか?その数値を自分で定義し、挙動を確認するプロセスこそが、シミュレーターに「魂」を込める作業なのだと実感しています。