提问 发文

行人仿真&仿而不真——基于Unity将外部仿真数据可视化

微微菌

| 2024-04-01 17:02 205 0 0

0 前言
Unity作为一款生态成熟、扩展性强、学习成本较低的三维引擎,近年来受到各领域研究者的青睐。具体到行人仿真领域,相较于传统的C++/Python平台,Unity在效果呈现及数据交互方面具备无可比拟的优势,国外开发者基于Unity已经实现了诸多惊艳的行人仿真项目。然而,将仿真过程的运算层与展示层全部置于Unity环境中可能并不是最完备的解决方案,研究者可能会面临以下难点:

(1)同时计算轨迹并渲染场景,性能开销巨大;

(2)将既有的仿真程序改写为C#脚本耗费时间且面临风险,尤其是在不熟悉Unity开发环境的情况下;

(3)Unity Editor本身就是一个十分耗资源的“重应用”,若在Editor中进行编译、调试,耗时长效率低;

(4)运算层与展示层之间数据不能解耦,一错则全错。

因此,如何将外部仿真数据批量、稳定地在Unity中进行呈现,是一个值得探究的问题。本文最终实现效果如下所示。



1 数据来源
仅仅考虑行人轨迹点数据,建议输出为csv、json等文件为佳,字符格式不易出错,同时可读性较好。由于仅仅是考虑实现方法,没必要太严谨,这里先用Python脚本随机生成一下轨迹点凑合用。注:生成行人的数量、轨迹点的数量在脚本开头处可以自己调,默认为生成10个行人,每个行人20个轨迹点;此外,x/y/z三个方向上的随机增量也可以根据自己喜好来,我这里暂且都用的正增量。

import os
import random

num_of_pedestrian = 10
num_of_path_point = 20
x_location_list = []
y_location_list = []
z_location_list = []
output_file_type = "csv"

def generate_output_path():
cur_dir = os.path.dirname(os.path.abspath(__file__))
i = 1
while i <= num_of_pedestrian:
output_path = cur_dir + "\PedestrianPath\Path-" + str(i) + "." + output_file_type
print(output_path)
x_location = 0
y_location = 0
z_location = 0
k = 0
while k < num_of_path_point:
delta_x = random.uniform(0.8,1.2)
delta_y = random.uniform(0.8,1.2)
delta_z = random.uniform(0.8,1.2)

x_location = x_location + delta_x
y_location = y_location + delta_y
z_location = z_location + delta_z

x_location_list.append(x_location)
y_location_list.append(y_location)
z_location_list.append(z_location)
k += 1
title_content = "坐标点编号,X坐标,Y坐标,Z坐标" + "\n"
file = open(output_path, "w")
file.write(title_content)
n = 0
while n <num_of_path_point:
line_content = str(n+1) + "," + str(x_location_list[n]) +","+ str(y_location_list[n]) + "," + str(z_location_list[n]) + "\n"
file.write(line_content)
n += 1
file.close()
x_location_list.clear()
y_location_list.clear()
z_location_list.clear()

i += 1

if __name__ == '__main__':
print("<<<<<<<<<<<<<<<<<开始生成行人路径坐标点>>>>>>>>>>>>>>>>")
generate_output_path()
print("<<<<<<<<<<<<<<<<已将生成坐标点输出至文件>>>>>>>>>>>>>>>>")



2 在Unity环境中读取轨迹点坐标
将生成的轨迹点文件导入到Unity工程文件夹中,规范一点的做法是相应新建一个Folder专门储存轨迹点文件。Unity引擎/C#解析csv文件的相关脚本墙内外都有很多现成的,按自己的需求改改就能用。

新建一个脚本挂到行人(随便什么物体,你觉得是行人就行)上,首先声明类名称ReadCSV,同时在类开头部分定义需要用到的列表,平面运动就先不考虑Y轴增量了,因此只定义了两个float list。下一步,在void start()中,即第一帧开始运行时加入读取csv文件相关代码。

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

public class ReadCSV : MonoBehaviour
{
List<float> lstXLocation = new List<float>();
List<float> lstZlocation = new List<float>();
public string CSVPath = "J:/Unity Projects/Import-And-Generate-Path/Assets/Path-1.csv";
//应与轨迹点文件路径保持一致↑↑↑
//后续严谨起见,也应把路径放到string list中
void Start()
{
//实例化StreamReader用于指定路径,后续路径也要编成表,或是规范化存于一个表中
FileStream fs = new FileStream(CSVPath, FileMode.Open);
StreamReader sr = new StreamReader(fs);
//读取参数设置
string[] read;
char[] seperators = { ',' };
string data = sr.ReadLine();
int LineCount = 1;
//开始读取
while ((data = sr.ReadLine()) != null)
{
read = data.Split(seperators, StringSplitOptions.RemoveEmptyEntries);
float ValueX = float.Parse(read[1]);
float ValueZ = float.Parse(read[3]);
//Debug.Log("第" + LineCount + "个路径点的X坐标为:" + ValueX + ",Z坐标为:" + ValueZ + "。");
//必要时开启↑
lstXLocation.Add(ValueX);
lstZlocation.Add(ValueZ);
LineCount++;
}
}
}


3 制作轨迹点预制体
在场景中新建一个三维物体(sphere/cube/capsule随便什么劳什子),大小适中,顺便上个色;最后拖到Asset资源面板中,便可生成一个预制体。此外,为了使轨迹点在场景中更加醒目,可以为其增添闪烁效果,推荐用这个手搓脚本,不依赖外部库非常稳定。打开预制体,把这个脚本挂到轨迹点物体上就行了,可以自行对亮度/周期/颜色/是否循环等参数进行设置。


using System.Collections;
using UnityEngine;

public class Skode_Glinting : MonoBehaviour
{
/// <summary>
/// 闪烁颜色
/// </summary>
public Color color = new Color(61 / 255f, 226 / 255f, 131 / 255, 1);

/// <summary>
/// 最低发光亮度,取值范围[0,1],需小于最高发光亮度。
/// </summary>
[Tooltip("最低发光亮度,取值范围[0,1],需小于最高发光亮度。")]
[Range(0.0f, 1.0f)]
public float minBrightness = 0.0f;

/// <summary>
/// 最高发光亮度,取值范围[0,1],需大于最低发光亮度。
/// </summary>
[Tooltip("最高发光亮度,取值范围[0,1],需大于最低发光亮度。")]
[Range(0.0f, 1)]
public float maxBrightness = 0.5f;

/// <summary>
/// 闪烁频率,取值范围[0.2,30.0]。
/// </summary>
[Tooltip("闪烁频率,取值范围[0.2,30.0]。")]
[Range(0.2f, 30.0f)]
public float rate = 1;

//是否闪烁
[HideInInspector]
public bool isGlinting = false;


[Tooltip("勾选此项则启动时自动开始闪烁")]
[SerializeField]
private bool _autoStart = false;

private float _h, _s, _v; // 色调,饱和度,亮度
private float _deltaBrightness; // 最低最高亮度差
private Renderer _renderer;

//private Material _material;
private Material[] _materials;

private readonly string _keyword = "_EMISSION";
private readonly string _colorName = "_EmissionColor";

private Coroutine _glinting;

private void OnEnable()
{
_renderer = gameObject.GetComponent<Renderer>();

//_material = _renderer.material;
_materials = _renderer.materials;

if (_autoStart)
{
StartGlinting();
}
}

/// <summary>
/// 校验数据,并保证运行时的修改能够得到应用。
/// 该方法只在编辑器模式中生效!!!
/// </summary>
private void OnValidate()
{
// 限制亮度范围
if (minBrightness < 0 || minBrightness > 1)
{
minBrightness = 0.0f;
Debug.LogError("最低亮度超出取值范围[0, 1],已重置为0。");
}
if (maxBrightness < 0 || maxBrightness > 1)
{
maxBrightness = 1.0f;
Debug.LogError("最高亮度超出取值范围[0, 1],已重置为1。");
}
if (minBrightness >= maxBrightness)
{
minBrightness = 0.0f;
maxBrightness = 1.0f;
Debug.LogError("最低亮度[MinBrightness]必须低于最高亮度[MaxBrightness],已分别重置为0/1!");
}

// 限制闪烁频率
if (rate < 0.2f || rate > 30.0f)
{
rate = 1;
Debug.LogError("闪烁频率超出取值范围[0.2, 30.0],已重置为1.0。");
}

// 更新亮度差
_deltaBrightness = maxBrightness - minBrightness;

// 更新颜色
// 注意不能使用 _v ,否则在运行时修改参数会导致亮度突变
float tempV = 0;
Color.RGBToHSV(color, out _h, out _s, out tempV);
}

/// <summary>
/// 开始闪烁。
/// </summary>
public void StartGlinting()
{
isGlinting = true;
if (_materials != null)
{
if (_materials.Length > 0)
{
//_material.EnableKeyword(_keyword);
for (int i = 0; i < _materials.Length; i++)
{
_materials[i].EnableKeyword(_keyword);
}

if (_glinting != null)
{
StopCoroutine(_glinting);
}
_glinting = StartCoroutine(IEGlinting());
}
}
}

/// <summary>
/// 停止闪烁。
/// </summary>
public void StopGlinting()
{
isGlinting = false;
//_material.DisableKeyword(_keyword);
for (int i = 0; i < _materials.Length; i++)
{
_materials[i].DisableKeyword(_keyword);
}

if (_glinting != null)
{
StopCoroutine(_glinting);
}
}

/// <summary>
/// 控制自发光强度。
/// </summary>
/// <returns></returns>
private IEnumerator IEGlinting()
{
Color.RGBToHSV(color, out _h, out _s, out _v);
_v = minBrightness;
_deltaBrightness = maxBrightness - minBrightness;

bool increase = true;
while (true)
{
if (increase)
{
_v += _deltaBrightness * Time.deltaTime * rate;
increase = _v <= maxBrightness;
}
else
{
_v -= _deltaBrightness * Time.deltaTime * rate;
increase = _v <= minBrightness;
}
//_material.SetColor(_colorName, Color.HSVToRGB(_h, _s, _v));

for (int i = 0; i < _materials.Length; i++)
{
_materials[i].SetColor(_colorName, Color.HSVToRGB(_h, _s, _v));
}
//_renderer.UpdateGIMaterials();
yield return null;
}
}
}


在完成上述设置后,我们的路径点应该跟下面图中的差不多。当然,严谨起见,我们应当在项目中新建Resources文件夹,并将所有场景中可能用的到预制体放入其中。此外,为了方便我们后续在场景中查询、获取所有的轨迹点集合,应为轨迹点新建一个Tag,比如“PathPoint”,Tag Name跟场景中或是资源中其他物体重名也没关系。



4 导入行人模型,设置行走动画
4.1 模型设置
行人仿真总得有行人模型,无论通过何种方式获(bai)取(piao)模型,首先需要将我们的行人模型(FBX文件)放入Asset文件夹中,将Rig设为Humanoid,并点击Apply。随后人物模型在资源面板中会出现一个小绿人,点开它,执行Configure Avatar选项。至此,我们导入的行人模型就可以用了。



4.2 动画设置

由于是简单实现功能,不需要成套动画,推荐从Mixamo(Mixamo)上下载自己想要的动画。从Mixamo上下载的都是绑定动画的FBX文件,我们可以将其导入到Unity中后,复用绑定在其上的动画。




如同本地的人物模型一样,我们需要将来自Mixamo的模型的Rig设置为Humanoid。随后打开FBX文件,选中图中这个三角形(anim文件),Ctrl + D后再重命名(我下的是一个行走动画,为了好区分直接命名Walking),我们就可以将人物动画拿来用到别处了。须注意的是,动画应勾选Loop Time以允许循环播放。

随后在Asset面板中新建一个Animation Controller,并将Entry后的默认状态设为Walking(需要在面板中单击右键新建状态),为该状态分配动画(.anim),也就是我们之前提到过的三角形。



4.3 场景设置
将4.1中调整过的本地人物模型拖到场景中,为其添加Animator组件,如果之前人形动画配置正确,应该可以看到模型已经被分配默认的Avatar组件,就是我们刚刚生成的。确认无误后,为人物模型分配刚刚新建的Animation组件。此外,我们还需要将刚刚建立的ReadCSV脚本挂载到人物模型上,该脚本将作为模型的组件来实现数据读取、运动控制的功能。




5 实例化轨迹点

public void GeneratePathPoint(int LineCount)
{
GameObject Container = new GameObject("PathPointContainer");
int NumOfLine = LineCount;
int i = 1;
while (i < NumOfLine)
{
//为每个实例化的路径点命名
string Name = "PathPoint-" + i.ToString();
GameObject PathPoint = PathPointPrefab;
PathPoint.transform.name = Name;
//定义实例化位置,生成路径点,并设为Container的子物体
Vector3 Position = new Vector3(lstXLocation[i-1], 0.5f, lstZlocation[i-1]);
Instantiate(PathPoint);
PathPoint.transform.localPosition = Position;
Debug.Log("已经实例化第" + i + "个路径点");
i++;
}
}


这里把实例化路径点的相关代码单独抽出来写,放在ReadCSV的类中就可以。最后需要在void start()中调用一次,由于该方法依赖读取csv文件的结果,因此需要放在读取路径点的相关代码之后调用。


void Start()
{
GeneratePathPoint(LineCount);
}

6 依次向各个轨迹点运动
6.1 实现方法1——基于Navmesh Agent
该方法需要为行人模型添加Navmesh Agent组件,并烘焙导航网格,具体步骤如下:

(1)新建平面Plane,选择Window>AI>Navigation,打开导航界面,选择既有的Plane并进行烘焙。

(2)为行人模型添加Navmesh Agent Component,尺寸参数/寻路参数自己设一下就行。

(3)在ReadCSV脚本中添加如下代码,最终如下所示:


using System;
using UnityEngine;
using System.IO;
using System.Data;
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine.AI;

public class ReadCSV : MonoBehaviour
{
// Start is called before the first frame update
List<float> lstXLocation = new List<float>();
List<float> lstZlocation = new List<float>();
public string CSVPath = "J:/Unity Projects/Import-And-Generate-Path/Assets/Path-1.csv";
public GameObject PathPointPrefab;
public GameObject[] points;
private int destPoint = 0;
private NavMeshAgent agent;

void Start()
{
//实例化StreamReader用于指定路径,后续路径也要编成表,或是规范化存于一个表中
FileStream fs = new FileStream(CSVPath, FileMode.Open);
StreamReader sr = new StreamReader(fs);
//读取参数设置
string[] read;
char[] seperators = { ',' };
string data = sr.ReadLine();
int LineCount = 1;
//开始读取
while ((data = sr.ReadLine()) != null)
{
read = data.Split(seperators, StringSplitOptions.RemoveEmptyEntries);
float ValueX = float.Parse(read[1]);
float ValueZ = float.Parse(read[3]);
//Debug.Log("第" + LineCount + "个路径点的X坐标为:" + ValueX + ",Z坐标为:" + ValueZ + "。");
//必要时开启↑
lstXLocation.Add(ValueX);
lstZlocation.Add(ValueZ);
LineCount++;
}
GeneratePathPoint(LineCount);
points = GameObject.FindGameObjectsWithTag("PathPoint");
agent = GetComponent<NavMeshAgent>();
agent.autoBraking = false;
GotoNextPoint();
}

// Update is called once per frame
void Update()
{
if (!agent.pathPending && agent.remainingDistance < 0.5f)
GotoNextPoint();
}

public void GeneratePathPoint(int LineCount)
{
GameObject Container = new GameObject("PathPointContainer");
int NumOfLine = LineCount;
int i = 1;
while (i < NumOfLine)
{
//为每个实例化的路径点命名
string Name = "PathPoint-" + i.ToString();
GameObject PathPoint = PathPointPrefab;
PathPoint.transform.name = Name;
//定义实例化位置,生成路径点,并设为Container的子物体
Vector3 Position = new Vector3(lstXLocation[i-1], 0.5f, lstZlocation[i-1]);
Instantiate(PathPoint);
PathPoint.transform.localPosition = Position;
Debug.Log("已经实例化第" + i + "个路径点");
i++;
}
}

void GotoNextPoint()
{
if (points.Length == 0)
return;
agent.destination = points[destPoint].transform.position;
destPoint = (destPoint + 1) % points.Length;
}
}


6.2 实现方法2——基于Transform

该方法比较简单朴素,依靠Transform类中的相关功能,不需要额外添加组件,在ReadCSV脚本中添加如下代码即可实现,最终如下所示:

using System;
using UnityEngine;
using System.IO;
using System.Data;
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine.AI;

public class ReadCSV : MonoBehaviour
{
// Start is called before the first frame update
List<float> lstXLocation = new List<float>();
List<float> lstZlocation = new List<float>();
public string CSVPath = "J:/Unity Projects/Import-And-Generate-Path/Assets/Path-1.csv";
public GameObject PathPointPrefab;
public GameObject[] wayPoint;
int nextPointIndex;

void Start()
{
//实例化StreamReader用于指定路径,后续路径也要编成表,或是规范化存于一个表中
FileStream fs = new FileStream(CSVPath, FileMode.Open);
StreamReader sr = new StreamReader(fs);
//读取参数设置
string[] read;
char[] seperators = { ',' };
string data = sr.ReadLine();
int LineCount = 1;
//开始读取
while ((data = sr.ReadLine()) != null)
{
read = data.Split(seperators, StringSplitOptions.RemoveEmptyEntries);
float ValueX = float.Parse(read[1]);
float ValueZ = float.Parse(read[3]);
//Debug.Log("第" + LineCount + "个路径点的X坐标为:" + ValueX + ",Z坐标为:" + ValueZ + "。");
//必要时开启↑
lstXLocation.Add(ValueX);
lstZlocation.Add(ValueZ);
LineCount++;
}
GeneratePathPoint(LineCount);
wayPoint = GameObject.FindGameObjectsWithTag("PathPoint");
//排序
Array.Sort(wayPoint, (x, y) => { return x.gameObject.name.CompareTo(y.gameObject.name); });
//设置初始位置
transform.position = wayPoint[0].transform.position;
//设置初始角度
transform.forward = wayPoint[nextPointIndex].transform.position - transform.position;
}

// Update is called once per frame
void Update()
{
//判断自身距离下一个路径点的位置
if (Vector3.Distance(wayPoint[nextPointIndex].transform.position, transform.position) < 0.1f)
{
//如果下一个路径点不是最后一个则加一
if (nextPointIndex != wayPoint.Length - 1)
{
nextPointIndex++;
}
//当自己的位置到达最后一个位置的时候 直接将自己的位置固定防止本体颤抖
if (Vector3.Distance(wayPoint[wayPoint.Length - 1].transform.position, transform.position) < 0.1f)
{
transform.position = wayPoint[wayPoint.Length - 1].transform.position;
return;
}
//设置每一个点的转向
transform.forward = wayPoint[nextPointIndex].transform.position - transform.position;
}
//前进
transform.Translate(Vector3.forward * 0.5f * Time.deltaTime, Space.Self);
}

public void GeneratePathPoint(int LineCount)
{
GameObject Container = new GameObject("PathPointContainer");
int NumOfLine = LineCount;
int i = 1;
while (i < NumOfLine)
{
//为每个实例化的路径点命名
string Name = "PathPoint-" + i.ToString();
GameObject PathPoint = PathPointPrefab;
PathPoint.transform.name = Name;
//定义实例化位置,生成路径点,并设为Container的子物体
Vector3 Position = new Vector3(lstXLocation[i-1], 0.5f, lstZlocation[i-1]);
Instantiate(PathPoint);
PathPoint.transform.localPosition = Position;
Debug.Log("已经实例化第" + i + "个路径点");
i++;
}
}
}


————————————————

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

原文链接:https://blog.csdn.net/lennonmao/article/details/127156187



收藏 0
分享
分享方式
微信

评论

游客

全部 0条评论

10603

文章

10.57W+

人气

19

粉丝

1

关注

官方媒体

轻松设计高效搭建,减少3倍设计改稿与开发运维工作量

开始免费试用 预约演示

扫一扫关注公众号 扫一扫联系客服

©Copyrights 2016-2022 杭州易知微科技有限公司 浙ICP备2021017017号-3 浙公网安备33011002011932号

互联网信息服务业务 合字B2-20220090

400-8505-905 复制
免费试用
微信社区
易知微-数据可视化
微信扫一扫入群