개발 기록/[CTSG] 25.06.01-

[디지털 트윈] Unity에서 SUMO 차량(fcd.xml) 시뮬레이션하기

Dachaes 2025. 7. 1. 13:34
728x90
728x90

Unity에서 SUMO 차량(fcd.xml) 시뮬레이션하기 

앞서 도시 모빌리티·환경 시뮬레이션을 위해 QGIS를 이용해 지형/건물/도로 데이터를 가상 환경에 올리는 작업을 진행했습니다. 이번에는 SUMO에서 제공하는 차량 위치 데이터 xml 파일을 이용하여 실제 교통 시뮬레이션을 만들어 보려고 합니다.

 


이전 글 보기

 

[디지털 트윈] Unity에 지형을 3D로 시각화하기 - 수치 지형도/정사 영상 편

Unity에 지형을 3D로 시각화하기 - 수치 지형도/정사 영상 편 디지털 트윈 프로젝트를 준비하며, 웹 브라우저 상에서 3D 지형을 구현해보려 합니다. 이를 위해 지형, 건물, 도로 등 다양한 공간 정보

dachaes-devlogs.tistory.com

 

[디지털 트윈] Unity에 건물을 3D로 시각화하기

Unity에 건물을 3D로 시각화하기 디지털 트윈 프로젝트를 준비하며, 웹 브라우저 상에서 3D 지도를 구현해보려 합니다. 이를 위해 지형, 건물, 도로 등 다양한 공간 정보를 시각화할 계획입니다. 우

dachaes-devlogs.tistory.com

 

[디지털 트윈] Unity에서 SUMO 도로 네트워크(.net.xml) 시각화하기

Unity에서 SUMO 도로 네트워크(.net.xml) 시각화하기 SUMO로 도로네트워크 시뮬레이션을 위해 도로 정보를 lane(차선) 단위로 분석·시각화하려고 합니다. 하지만 SUMO에서 자체적으로 변환한 GeoJSON 파일

dachaes-devlogs.tistory.com

 


개발 환경

  • Node v22.16.0
  • React v19.1.0 (TypeScript) + Vite
  • QGIS Desktop 3.42.3  3.44.0
  • Unity 6000.0.50f1 (LTS)

개발 순서

      1. 3D 도시 모델에 Collider 적용하기
      2. SUMO fcd 데이터 준비하기
      3. Object Pooling 으로 Unity 차량 오브젝트 관리하기
      4. fcd json 파일 파싱하기
      5. fcd 데이터와 유니티 좌표 동기화하기
      6. 차량의 높이를 지형과 맞추기

참고 사이트

 


1.  3D 도시 모델에 Collider 적용하기

QGIS에서 내보낸 gltf 파일을 Unity로 가져옵니다. 씬에 올린 뒤, DEM 오브젝트(예시의 경우에는 잘라낸 산출물 (마스크))에  Mesh Collider를 추가하여 차나 캐릭터가 실제로 지형 위를 달릴 수 있도록 충돌 처리를 해줍니다. 추가는 Add Component 버튼을 통해 가능합니다. 건물에도 충돌 처리를 하고싶으면, Mesh Renderer가 있는 각 산출물의 하위 오브젝트에도 Mesh Collider를 적용합니다.

지형이든 건물이든, Mesh Renderer가 있는 오브젝트에 Mesh Collider를 추가야 Mesh 모양대로 Collider가 적용됩니다.

 


2.  SUMO fcd 데이터 준비하기

SUMO에서는 도로 네트워크와 차량 움직임을 시뮬레이션할 수 있습니다. 차량의 경로와 스케줄 정보는 rou.xml 파일(route file, .rou.xml)로 관리하며, 여기서 차량의 종류, 운행 경로, 출발·도착 시간 등을 설정할 수 있습니다.
시뮬레이션을 실행하면 각 차량의 위치와 상태 정보가 프레임 단위(fcd-output)로 기록됩니다. Unity에서는 이 데이터를 활용해 실제 차량의 움직임을 재현할 수 있습니다.

<?xml version="1.0" encoding="UTF-8"?>
<fcd-export xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://sumo.dlr.de/xsd/fcd_file.xsd">
    <timestep time="0.00">
        <vehicle id="0" x="126.786753" y="37.328880" angle="209.73" type="DEFAULT_VEHTYPE" speed="0.00" pos="5.10" lane="481000712-AddedOnRampEdge_0" slope="0.00"/>
    </timestep>
    <timestep time="1.00">
        <vehicle id="0" x="126.786742" y="37.328864" angle="209.73" type="DEFAULT_VEHTYPE" speed="1.99" pos="7.09" lane="481000712-AddedOnRampEdge_0" slope="0.00"/>
        <vehicle id="1" x="126.790905" y="37.330271" angle="211.31" type="DEFAULT_VEHTYPE" speed="0.00" pos="5.10" lane="-58672524#3_0" slope="0.00"/>
        <vehicle id="2" x="126.820337" y="37.310274" angle="96.35" type="DEFAULT_VEHTYPE" speed="0.00" pos="5.10" lane="58531050#5_0" slope="0.00"/>
    </timestep>
    <timestep time="2.00">
        <vehicle id="0" x="126.786755" y="37.328823" angle="209.74" type="DEFAULT_VEHTYPE" speed="3.48" pos="10.57" lane="481000712-AddedOnRampEdge_1" slope="0.00"/>
        <vehicle id="1" x="126.790897" y="37.330260" angle="211.31" type="DEFAULT_VEHTYPE" speed="1.38" pos="6.48" lane="-58672524#3_0" slope="0.00"/>
        <vehicle id="2" x="126.820364" y="37.310272" angle="96.35" type="DEFAULT_VEHTYPE" speed="2.41" pos="7.51" lane="58531050#5_0" slope="0.00"/>
        <vehicle id="3" x="126.816388" y="37.333985" angle="95.13" type="DEFAULT_VEHTYPE" speed="0.00" pos="3.91" lane="744786916#3_0" slope="0.00"/>
        <vehicle id="4" x="126.815217" y="37.306326" angle="275.36" type="DEFAULT_VEHTYPE" speed="0.00" pos="5.10" lane="1330283856#2_0" slope="0.00"/>
    </timestep>
    .
    .
    .
    <timestep time="5625.00"/>
</fcd-export>

Back-end, Front-end, UNITY WebGL에서 주고 받는 형식은 .json입니다. xml 파일을 json 파일로 변환하는 코드를 작성합니다. xml 파일이 워낙 길다보니 파싱하고 변환하는데 시간이 오래 걸리니 기다려야 합니다. 마지막 데이터까지 제대로 파싱되어 있는지 확인하고 넘어 갑시다.

import xml.etree.ElementTree as ET
import json
import os

def fcd_xml_to_json(xml_path, json_path):
    tree = ET.parse(xml_path)
    root = tree.getroot()

    result = []

    for timestep in root.findall('timestep'):
        time = float(timestep.attrib['time'])
        vehicles = []
        for v in timestep.findall('vehicle'):
            vehicle_info = {k: v.attrib[k] for k in v.attrib}
            # 숫자 변환 (id, type, lane은 문자열로 남김)
            for key in ['x', 'y', 'angle', 'speed', 'pos', 'slope']:
                if key in vehicle_info:
                    vehicle_info[key] = float(vehicle_info[key])
            vehicles.append(vehicle_info)
        result.append({
            "time": time,
            "vehicles": vehicles
        })

    with open(json_path, 'w', encoding='utf-8') as f:
        json.dump(result, f, ensure_ascii=False, indent=2)

if __name__ == '__main__':
    xml_file = 'results_fcd2.xml'
    json_file = 'results_fcd2.json'
    if not os.path.isfile(xml_file):
        print(f"입력 파일({xml_file})이 현재 폴더에 없습니다.")
    else:
        fcd_xml_to_json(xml_file, json_file)
        print(f"변환 완료! 결과: {json_file}")

 


3.  Object Pooling 으로 Unity 차량 오브젝트 관리하기

미리 일정 수의 차량 오브젝트를 생성하여 필요할 때마다 꺼내서 위치/방향만 갱신해서 사용하고, 더 이상 필요 없는 차량은 비활성화(Deactivate)해서 풀에 다시 넣습니다. 전체 차량 수에 따라 풀 크기를 동적으로 조절 가능합니다. Unity에서 Object Pooling 를 사용하면 차량 오브젝트를 효율적으로 관리할 수 있습니다.

a-1.  Vehicle 오브젝트 생성하기 (Asset을 사용할 거라면 패스)

다음은 차량 오브젝트 프리팹을 생성해야 합니다. Hierarchy를 우클릭하여 Cube Object를 생성하고, 이름을 Vehicle로 변경합니다. fcd 데이터를 통해 위치 값으로 차량을 이동시키므로 RigidBody Component는 필요하지 않으나, 실제 차량처럼 움직이는 로직을 넣는다면  RigidBody도 추가하는 것이 좋습니다.

시뮬레이션이 용이하도록 Vehicle 오브젝트의 크기를 조정하고, 색상(Material)을 입힙니다. 색상은 Create > Material 을 통해 Material을 먼저 생성합니다. 생성된 New Material의 Inspector에서 Shader 를 Universal Render Pipeline/Lit으로 설정하고, Surface Inputs > Base Map 을 원하는 색으로 변경합니다. Project 탭의 Material을 드래그하여 Scene의 Vehicle 오브젝트에 드랍하면 색상이 적용됩니다.

Hierarchy 탭의 Vehicle 오브젝트를 Project 탭에 드래그 앤 드랍하고, Hierarchy 탭의 Vehicle 오브젝트를 삭제하면 오브젝트 풀링에 사용할 오브젝트 생성이 완료됩니다.

a-2.  Vehicle Asset 을 import 하여 사용하기

Unity Asset Store 에서 원하는 에셋을 선택하고, 내 에셋에 추가하기를 클릭합니다. 그리고 Unity에서 열기 버튼을 누르면, 진행하고 있던 Unity 프로젝트에 Package Manager 창이 하나 열립니다. 에셋들을 import 합니다.

에셋스토어의 대부분의 무료/유료 에셋들은 기본(Built-in) Render Pipeline 환경에서 만들어졌습니다. 하지만 요즘 Unity는 URP나 HDRP 등 새로운 파이프라인을 많이 사용합니다. 이 두 환경은 Shader와 머티리얼 구조가 달라서, Built-in용 에셋을 URP 프로젝트에 그대로 넣으면 Shader를 못 읽고 핑크색이 되어버립니다.

이를 해결하려면 상단 탭의 Window > Rendering > Render Pipeline Converter 창에서 옵션을 모두 체크하고, Initialize And Convert 버튼을 눌러줍니다.

b.  VehiclePool 스크립트 작성하기 (오브젝트 풀링)

우선 Assets 폴더에 Scripts > Vehicle 폴더를 생성하고, 빈 공간을 우클릭하여 MonoBehavour Script를 생성합니다. Project 탭에서 MonoBehavour Script 를 생성합니다. 이름을 'VehiclePool'로 바꿔주고, 더블 클릭하여 엽니다.

using System.Collections.Generic;
using UnityEngine;

public class VehiclePool : MonoBehaviour
{
    public static VehiclePool Instance { get; private set; }

    public GameObject vehiclePrefab;
    public int initialPoolSize = 100;

    private Queue<GameObject> pool = new Queue<GameObject>();
    private HashSet<GameObject> activeVehicles = new HashSet<GameObject>();

    private void Awake()
    {
        if (Instance == null) Instance = this;
        else Destroy(gameObject);

        // 초기 풀 채우기
        for (int i = 0; i < initialPoolSize; i++)
        {
            GameObject vehicle = Instantiate(vehiclePrefab, transform);
            vehicle.SetActive(false);
            pool.Enqueue(vehicle);
        }
    }

    // 차량 오브젝트 꺼내오기
    public GameObject GetVehicle()
    {
        GameObject vehicle;
        if (pool.Count > 0)
        {
            vehicle = pool.Dequeue();
        }
        else
        {
            vehicle = Instantiate(vehiclePrefab, transform);
        }
        vehicle.SetActive(true);
        activeVehicles.Add(vehicle);
        return vehicle;
    }

    // 사용이 끝난 차량 오브젝트 돌려놓기
    public void ReturnVehicle(GameObject vehicle)
    {
        vehicle.SetActive(false);
        pool.Enqueue(vehicle);
        activeVehicles.Remove(vehicle);
    }
}

VehiclePool 스크립트를 붙일 Empty 오브젝트를 생성하고, Vehicle Pool Manager로 이름을 변경합니다. Vehicle Pool Manager의 Inspector에 VehiclePool 스크립트를 드래그 앤 드랍해주고, Vehicle Prefab에 앞서 만들었던 Vehicle 오브젝트(혹은 다운받은 Vehicle Asset)를 스크립트와 동일한 방식으로 끌어서 놓습니다.

여기까지 하고, Unity를 실행하면 Vehicle Pool Manager의 하위에 Vehicle(Clone) 오브젝트가 100개 생성된 것을 볼 수 있습니다.

 


4.  fcd json 파일 파싱하기

fcd-output 파일을 읽어 각 timestep(프레임)마다 차량 ID, 위치, 각도, 속도 등을 파싱합니다. 각 차량 ID별로 풀에서 오브젝트를 찾아서 실시간으로 위치/회전 값을 반영하고, 새로 등장하는 차량은 풀에서 꺼내어 활성화합니다. 차량이 시뮬레이션에서 사라지면 비활성화 처리합니다.

시뮬레이션에서 차량 수가 많아지면 오브젝트를 매번 생성/파괴하게 되고, 이 경우에 성능 저하가 심하게 나타납니다. 이를 해결하기 위해, Unity의 Object Pooling(오브젝트 풀링) 기법을 사용합니다.

코드 작성 (기본)

Project 탭에서 새 MonoBehavour Script를 3개 생성합니다. 각각 이름을 'TimeStep', 'Vehicle' , 'FcdParser'로 바꿔주고, 더블 클릭하여 엽니다.

using System.Collections.Generic;

public class TimeStep
{
    public float time { get; set; }
    public List<Vehicle> vehicles { get; set; }
}
public class Vehicle
{
    public string id { get; set; }
    public double x { get; set; }
    public double y { get; set; }
    public float angle { get; set; }
    public string type { get; set; }
    public float speed { get; set; }
    public float pos { get; set; }
    public string lane { get; set; }
    public float slope { get; set; }
}
using UnityEngine;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.IO;

public class FcdFarser : MonoBehaviour
{
    [Header("SUMO FCD JSON 파일")]
    public TextAsset fcdJsonFile;

    [Header("시간")]
    public int timer = 0;
    private float elapsed = 0f;

    [Header("차량 정보")]
    // time별로 json에서 파싱한 차량 정보 저장
    public Dictionary<int, List<Vehicle>> timeToVehicles = new Dictionary<int, List<Vehicle>>();
    // id별로 활성화된 차량 오브젝트를 추적
    private Dictionary<string, GameObject> activeVehiclesById = new Dictionary<string, GameObject>();

    private void OnValidate()
    {
        if (fcdJsonFile == null)
        {
            Debug.LogError("FcdFarser: FCD JSON 파일이 지정되지 않았습니다.", this);
        }
    }
    private void Start()
    {
        timer = 0;
        elapsed = 0f;
        ParseAllTimeSteps();
        UpdateCarObjects(0);
    }

    private void Update()
    {
        elapsed += Time.deltaTime;
        if (elapsed >= 1f)
        {
            timer += 1;
            elapsed -= 1f;
            
            UpdateCarObjects(timer);
        }
    }

    // 차량 오브젝트 풀링 및 위치 처리 함수
    public void UpdateCarObjects(int timer)
    {
        // 1. 현재 time의 차량 정보 불러오기
        List<Vehicle> currentVehicles = null;
        timeToVehicles.TryGetValue(timer, out currentVehicles);
        if (currentVehicles == null)
            currentVehicles = new List<Vehicle>();

        // 2. 현재 time의 차량 ID 목록 생성
        HashSet<string> currentIds = new HashSet<string>();
        foreach (var currentVehicle in currentVehicles)
            currentIds.Add(currentVehicle.id);

        // 3. 더 이상 필요없는 차량 오브젝트 반납
        var idsToRemove = new List<string>();
        foreach (var activeVehicle in activeVehiclesById)
        {
            if (!currentIds.Contains(activeVehicle.Key))
            {
                VehiclePool.Instance.ReturnVehicle(activeVehicle.Value);
                idsToRemove.Add(activeVehicle.Key);
            }
        }
        foreach (var id in idsToRemove)
            activeVehiclesById.Remove(id);

        // 4. 현재 vehicles 리스트 처리 (활성화 및 위치/회전 세팅)
        foreach (var currentVehicle in currentVehicles)
        {
            GameObject carObj;
            // 새 차량이면 → 풀에서 꺼내고 등록, 기존 차량이면 carObj에 저장
            if (!activeVehiclesById.TryGetValue(currentVehicle.id, out carObj))
            {                
                carObj = VehiclePool.Instance.GetVehicle();
                activeVehiclesById[currentVehicle.id] = carObj;
            }

            // 위치/회전 세팅 (여기서 x, y → Unity 좌표계 변환 필요 시 적용)
            carObj.transform.position = new Vector3((float)currentVehicle.x, 0, (float)currentVehicle.y);
            carObj.transform.rotation = Quaternion.Euler(0, currentVehicle.angle, 0);

            // id, x, y, angle 값 콘솔에 출력
            Debug.Log($"[timer={timer}] id:{currentVehicle.id}, x:{currentVehicle.x:F8}, y:{currentVehicle.y:F8}, angle:{currentVehicle.angle}");

        }
    }

   // 모든 timestep에 대해 한 번에 파싱하는 함수
   private void ParseAllTimeSteps()
   {
     if (fcdJsonFile == null) return;

     timeToVehicles.Clear();
     using (var stringReader = new StringReader(fcdJsonFile.text))
     using (var jsonReader = new JsonTextReader(stringReader))
     {
         var serializer = new JsonSerializer();

         // JSON 배열 시작까지 이동
         if (!jsonReader.Read() || jsonReader.TokenType != JsonToken.StartArray)
             return;

         while (jsonReader.Read())
         {
             if (jsonReader.TokenType == JsonToken.StartObject)
             {
                 JObject obj = JObject.Load(jsonReader);
                 float time = obj["time"].Value<float>();
                 var vehicles = obj["vehicles"].ToObject<List<Vehicle>>(serializer);
                 timeToVehicles[(int)time] = vehicles;
             }
             else if (jsonReader.TokenType == JsonToken.EndArray)
             {
                 break;
             }
         }
     }
 }
}

JSON 파싱을 위해 Newtonsoft Json 패키지(Json.NET)를 설치해야 합니다. 상단 탭의 Window > Packge Manager 창을 열고, + > install package by name > com.unity.nuget.newtonsoft-json 을 검색해서 Newtonsoft Json 패키지 설치를 합니다.

FcdPaser 스크립트를 붙일 Empty 오브젝트를 생성하고, Fcd Paser로 이름을 변경합니다. Fcd Paser의 Inspector에 Fcd Paser 스크립트를 드래그 앤 드랍합니다. 그리고 스크립트에 json파일을 끌어 놓습니다.

 


5.  fcd 데이터와 유니티 좌표 동기화하기

지형(건물, 도로 등) 3D 오브젝트는 QGIS에서 glTF 파일로 추출해 Unity로 임포트한 상태입니다. 이 때문에, 지형 모델은 더 이상 위경도 좌표를 갖고 있지 않고 Unity의 월드 좌표만을 사용합니다. 반면, 차량 데이터(FCD)는 여전히 위경도 기반으로 움직이기 때문에 두 데이터의 위치를 직접적으로 맞추는 데 어려움이 있습니다.

Open Street Map 홈페이지에서 특정 지점의 유니티 월드 좌표와 매핑되는 위치의 대략적인 위경도 값을 가져옵니다.

  • 1: (OSM-위경도) 37.325499240 N, 126.749313918 E → (Unity) 1164, 0, -1442.8
  • 2: (OSM-위경도) 37.325551980 N, 126.775580175 E → (Unity) -1164, 0, -1442.8
  • 3: (OSM-위경도) 37.299496051 N, 126.749400238 E → (Unity) 1164, 0, 1442.8
  • 4: (OSM-위경도) 37.299550733 N, 126.775657453 E → (Unity) -1164, 0, 1442.8
// 바운딩 박스 기준점 (위경도/유니티 매핑)
const double lonMin = 126.749313918;   // 경도 최소값 (OSM)
const double lonMax = 126.775657453;   // 경도 최대값 (OSM)
const double latMin = 37.299496051;    // 위도 최소값 (OSM)
const double latMax = 37.325551980;    // 위도 최대값 (OSM)

const float unityXMin = 1164f;         // 경도 최소값에 해당하는 유니티 X
const float unityXMax = -1164f;        // 경도 최대값에 해당하는 유니티 X
const float unityZMin = 1442.8f;       // 위도 최소값에 해당하는 유니티 Z
const float unityZMax = -1442.8f;      // 위도 최대값에 해당하는 유니티 Z

// 위경도(경도, 위도) → Unity 월드 좌표 변환 함수
public static Vector3 GeoToUnity(double lon, double lat, float y = 5f)
{
    // 선형 보간(LERP)
    float unityX = (float)(
        unityXMin + (unityXMax - unityXMin) * ((lon - lonMin) / (lonMax - lonMin))
    );
    float unityZ = (float)(
        unityZMin + (unityZMax - unityZMin) * ((lat - latMin) / (latMax - latMin))
    );
    return new Vector3(unityX, y, unityZ);
}
// 위경도 → 유니티 좌표 변환
Vector3 worldPos = GeoToUnity(currentVehicle.x, currentVehicle.y, 5f);
carObj.transform.position = worldPos;
carObj.transform.rotation = Quaternion.Euler(0, currentVehicle.angle, 0);

다소 오차가 있어보이나, 일단 차량의 움직임이 도로를 따라 잘 표현이 되는 것을 볼 수 있습니다.

 


6.  차량의 높이를 지형에 맞추기

fcd 데이터에는 차량의 높이(z) 정보가 없기 때문에, 유니티의 지형에 파묻히는 현상이 간혹 발생합니다. 앞서, 지형의 Mesh Renderer 컴포넌트가 있는 오브젝트마다 Mesh Collider 컴포넌트를 추가하는 작업을 진행했었습니다.

이제 차량의 x, y 정보를 Unity에 맞게 변환하였으니, 해당 위치를 기준으로 지형에 Laycast를 쏘아 지형의 고도 값을 가져오려고 합니다.

참고로 차량 에셋에 Collider가 포함되어 있다면, 이 컴포넌트는 해제해야 합니다. 차량이 멈춘 채 동일한 위치에 있는 경우, 매초마다 해당 위치에서 Laycast를 쏘게 되는데, 이때 차량 자신의 Collider가 Laycast에 걸릴 수 있습니다. 그 결과 차량이 반복적으로 위로 상승하는 현상이 생기니 주의해야 합니다.

// 위경도 → 유니티 좌표 변환
Vector3 worldPos = GeoToUnity(currentVehicle.x, currentVehicle.y, 50f);

// 유니티 y좌표를 고도에 맞추어 변환 (차량을 바닥에 붙이는 작업)
RaycastHit hit;
if (Physics.Raycast(worldPos, Vector3.down, out hit, 1000f))
    worldPos.y = hit.point.y + 0f;
else
{
    if (Physics.Raycast(worldPos, Vector3.up, out hit, 1000f))
        worldPos.y = hit.point.y + 0f;
    else
        Debug.LogWarning($"[timer={timer}] id:{currentVehicle.id} - Raycast Missed!");
}

carObj.transform.rotation = Quaternion.Euler(0, currentVehicle.angle + 180, 0);
carObj.transform.position = worldPos;

결국 이러한 방식은 매초마다 대량의 차량 위치를 동시에 갱신하는 구조이므로 성능 저하를 유발할 수 있습니다. 이를 개선하기 위해, 차량 위치 업데이트를 여러 프레임에 나누어 분산 처리하도록 변경하였습니다. 이로 인해 한 프레임에 모든 차량을 동시에 처리하는 것이 아니라, 여러 프레임에 나누어 분산 처리함으로써 순간적인 CPU/GPU 부하를 줄이고, 카메라 움직임이나 사용자 인터페이스의 끊김 현상을 완화할 수 있습니다.

public void UpdateCarObjectsDistributed(int timer, int frameDivide = 5)
{
   StopAllCoroutines(); // 이전 코루틴 중단
   StartCoroutine(UpdateCarObjectsCoroutine(timer, frameDivide));
}

private IEnumerator UpdateCarObjectsCoroutine(int timer, int frameDivide)
{
    .
    .
    .
}

 


7.  실행 결과

 


 

728x90