资讯动态

Unity人群模拟系统:从迪杰斯特拉距离场到最优步长模型实践

发布时间:2026/8/8 7:47:07 来源:尧图企业网站定制
1. 项目概述从零构建Unity人群模拟系统如果你正在开发一款需要大量NPC非玩家角色的游戏或者想为你的城市规划、应急疏散、大型活动模拟等项目添加动态的人群那么“人群模拟”这个技术点你一定绕不开。我最近在做一个大型商场人流分析的可视化项目核心需求就是模拟成百上千个虚拟行人在复杂环境中的移动、避障和路径选择。市面上虽然有一些现成的插件但要么太贵要么不够灵活无法满足我们自定义行为逻辑的需求。于是我决定基于一个经典的学术项目——TUM的《Crowd Simulation and Visualization in Unity》——来自己动手搭建一套。这个项目本质上是一个在Unity中实现的、结合了路径规划和简化社会力模型的人群模拟与可视化演示。它没有使用Unity内置的NavMesh而是采用了更底层、更可控的迪杰斯特拉Dijkstra距离场算法进行全局路径规划并配合一个简化版的最优步长模型Optimal Steps Model来处理个体间的局部避障。这种组合方式在学术和工业界都很常见因为它平衡了计算效率和模拟的真实感。对于Unity开发者来说理解这套机制不仅能让你做出更智能的群体AI更能让你深入理解游戏AI和模拟系统的底层逻辑无论是用于游戏开发、建筑可视化还是数字孪生应用都非常有价值。2. 核心原理与架构设计拆解在动手写代码之前我们必须搞清楚人群模拟到底在模拟什么。简单来说它模拟的是每个个体行人在环境地图中从起点到目标点的移动过程。这个过程需要解决两个核心问题“我该往哪走”全局路径规划和**“我怎么避开路上的障碍和其他人”**局部碰撞避免与行为。这个TUM项目正是用两套独立的系统来分别应对这两个问题。2.1 全局导航基石迪杰斯特拉距离场Unity自带的NavMesh是个黑盒对于需要高度定制化路径代价例如不同地形有不同的移动成本或者需要动态避让危险区域的场景就显得力不从心。这个项目选择了迪杰斯特拉算法来构建一个2D网格距离场。它的工作流程是这样的首先我们将整个3D游戏世界“拍扁”到一个2D网格上每个网格单元Cell代表一小块区域。算法以目标点所在的网格为起点向周围扩散计算每个网格到达目标所需要的最短“距离”。这里的“距离”可以是简单的步数也可以是加权后的代价比如草地比水泥地难走代价就更高。最终我们会得到一个距离场场中每个格子都存储着一个数值代表从该格子到目标点的最短代价。注意这里用的是迪杰斯特拉算法而不是更快的A*。这是因为距离场需要计算所有格子到目标点的距离而A*是点到点的最优路径搜索。构建全场距离信息迪杰斯特拉是更合适的选择。计算完成后任何一个行人只需要查看自己所在格子的八个邻居选择距离值最小的那个格子走过去就一定能沿着“代价下降最快的方向”最终抵达目标。这就是梯度下降法在路径寻找中的应用。2.2 局部行为核心简化最优步长模型有了全局方向行人还需要处理眼前的障碍——主要是其他行人。如果只按照距离场走所有人会挤成一团出现极不真实的穿透现象。项目引入了一个简化的最优步长模型来处理局部避障。你可以把它想象成行人在每一步移动前都会做一次快速的“推演”基于当前速度、期望方向来自距离场和周围行人的位置在个人移动能力范围内一个扇形区域采样几个可能的下一步位置。然后通过一个评估函数计算每个候选位置的“合意度”这个函数通常会考虑是否离目标更近了是否和其他人靠得太近产生排斥力是否和自己预期的方向一致最后选择评估分数最高的那个位置作为下一步的实际落脚点。这个模型是对经典“社会力模型”的极大简化。社会力模型将行人视为受多种力目标吸引力、行人/障碍排斥力、自驱力作用的粒子需要求解复杂的微分方程计算量巨大。而最优步长模型通过离散采样和评估用更小的计算开销获得了可接受的避障效果非常适合实时模拟。2.3 项目整体架构一览理解了原理我们再看项目的代码结构就清晰了。核心脚本不多但分工明确Simulation.cs总控制器。负责初始化网格、生成行人、控制模拟循环Update中的逻辑步进。Grid.cs网格系统核心。负责3D世界坐标与2D网格坐标的相互转换管理网格状态空地、障碍、目标并生成用于可视化的网格平面。Pathfinding.cs路径查找核心。实现了迪杰斯特拉算法构建并维护全局距离场。Pedestrian.cs行人组件。挂在每个行人GameObject上存储其当前网格位置、速度、目标等状态并包含根据距离场和OSM计算下一步移动的逻辑。SimulationElement.cs模拟元素基类。可能用于标识场景中的可交互模拟物体。UIManager.cs SwitchScene.cs处理用户界面交互如开始/暂停、调整参数、切换场景。这种架构将数据网格、距离场、逻辑寻路、决策和表现移动、绘制进行了分离是构建可维护模拟系统的良好实践。3. 环境搭建与项目初始化实操理论说得再多不如动手跑起来。我们首先需要把项目环境搭建好。原项目指定了Unity 2019.4.3f1 LTS版本这是一个长期支持版非常稳定。我强烈建议你使用这个精确版本因为不同版本的Unity在API、渲染管线或包管理上可能有细微差别直接使用指定版本能避免许多不必要的兼容性问题。3.1 Unity版本管理与项目导入如果你电脑上已经安装了Unity Hub事情就简单了。在Hub的“安装”选项卡中添加2019.4.3f1版本。如果列表里没有可以到Unity官网下载该版本的安装器。安装时记得至少包含“Windows/Mac Build Support”和“WebGL Build Support”模块后者如果你想发布到网页端会用到。安装完成后通过Git克隆项目到本地或者直接下载ZIP包解压。打开Unity Hub选择“打开项目”定位到你解压的文件夹。Unity会开始导入项目并解析包依赖这可能需要几分钟。第一次打开时编辑器可能会重新编译所有脚本请耐心等待控制台Console窗口不再有错误输出。实操心得我遇到过在导入后控制台报CS0246找不到类型或命名空间的错误。这通常是因为包依赖没有正确解析。解决方法是在Unity编辑器中点击Window - Package Manager确保所有包都已就绪。更彻底的方法是删除项目根目录下的Library文件夹和Packages文件夹里的manifest.json文件然后重新打开项目让Unity彻底重建这些文件。不过删除Library前请确保项目没有未保存的更改。3.2 核心场景与参数初探项目打开后你大概率会看到一个或多个.unity场景文件。找到主场景可能是MainScene或SampleScene并双击打开。在Hierarchy面板中你应该能看到一个包含网格平面、一些立方体作为障碍物和目标点和空对象用于管理的场景。选中Simulation管理器对象在Inspector面板中你会看到Simulation.cs脚本暴露出的公共参数。这些是你的“控制台”非常重要Grid Width/Height定义了2D网格的尺寸。例如20x20意味着世界会被划分为400个格子。Cell Size每个格子在3D世界中的实际大小。这决定了网格的精细度和行人的移动粒度。Pedestrian Prefab行人的预制体引用。你可以在这里拖入自己制作的角色模型。Number of Pedestrians初始生成的行人数量。Simulation Speed模拟速度倍率。大于1加速小于1慢放用于调试和观察。花点时间调整这些参数比如把行人数量调到50然后点击运行。你应该能看到一群小球默认行人预制体从随机位置向目标点红色立方体移动并尝试彼此避开。4. 核心模块深度解析与代码实现现在我们深入最核心的三个模块网格系统、路径寻找和行人逻辑。我会结合代码解释关键实现并分享一些优化和调试技巧。4.1 Grid.cs世界与网格的桥梁Grid.cs脚本是连接3D视觉世界和2D逻辑网格的纽带。它的核心是两个静态方法WorldToCell和CellToWorld。// 将世界坐标转换为网格坐标 public static Vector2Int WorldToCell(Vector3 worldPos, float cellSize, Vector3 gridOrigin) { int x Mathf.FloorToInt((worldPos.x - gridOrigin.x) / cellSize); int y Mathf.FloorToInt((worldPos.z - gridOrigin.z) / cellSize); // 注意是Z轴 return new Vector2Int(x, y); } // 将网格坐标转换回世界坐标通常返回格子中心点 public static Vector3 CellToWorld(Vector2Int cell, float cellSize, Vector3 gridOrigin) { float x gridOrigin.x cell.x * cellSize cellSize * 0.5f; float z gridOrigin.z cell.y * cellSize cellSize * 0.5f; // 注意是Z轴 return new Vector3(x, 0, z); // 假设地面高度为0 }关键点这里有一个新手极易踩坑的地方——坐标轴对应。在Unity的3D空间中水平面通常使用X和Z轴而2D网格我们习惯用X和Y。在代码中WorldToCell里我们把世界的Z坐标映射到了网格的Y坐标。在CellToWorld时也要对应回来。搞混会导致行人位置完全错乱。此外Grid.cs还负责用MeshFilter生成一个可视化的网格平面。这纯粹是为了调试和展示在实际产品中你可能需要更美观的地面模型来替代它。4.2 Pathfinding.cs距离场的构建与更新Pathfinding.cs中的GenerateDistanceField方法是算法的核心。它接受目标点网格坐标、障碍物网格坐标集合和网格尺寸作为输入。public int[,] GenerateDistanceField(Vector2Int target, HashSetVector2Int obstacles, int width, int height) { int[,] distanceField new int[width, height]; // 初始化所有格子为“无穷大” for(int x0; xwidth; x) for(int y0; yheight; y) distanceField[x,y] int.MaxValue; QueueVector2Int queue new QueueVector2Int(); distanceField[target.x, target.y] 0; queue.Enqueue(target); Vector2Int[] directions { /* 上、下、左、右、四个斜角方向 */ }; while(queue.Count 0) { Vector2Int current queue.Dequeue(); int currentDist distanceField[current.x, current.y]; foreach(var dir in directions) { Vector2Int neighbor current dir; // 检查邻居是否在网格内且不是障碍物 if(IsInGrid(neighbor) !obstacles.Contains(neighbor)) { // 计算代价直走为1斜走为√2≈1.4这里用整数近似 int cost (dir.x ! 0 dir.y ! 0) ? 14 : 10; // 10和14是常用整数近似 int newDist currentDist cost; if(newDist distanceField[neighbor.x, neighbor.y]) { distanceField[neighbor.x, neighbor.y] newDist; queue.Enqueue(neighbor); } } } } return distanceField; }算法细节这是一个典型的广度优先搜索BFS变体。它使用队列来确保按距离从小到大的顺序遍历格子。代码中使用了8方向曼哈顿距离对角线并对对角线移动赋予了更高的代价14 vs 10这比简单的4方向移动能产生更平滑、更自然的路径。注意事项距离场只需要在目标点改变或障碍物布局发生重大变化时重新计算。在模拟运行时如果目标固定这是一次性的开销。计算复杂度是O(N)N是网格格子数量。对于100x100的网格计算是即时的。但对于超大规模网格你可能需要考虑分块计算或使用更高效的算法如快速行进法FMM。4.3 Pedestrian.cs行人的决策与移动每个行人GameObject上的Pedestrian.cs脚本在每帧或每个模拟步长驱动其行为。其Update或FixedUpdate中的逻辑流程可以概括为获取期望方向读取自身当前网格坐标在全局距离场中的值检查8个邻居格子的距离值选择值最小的方向作为期望移动方向。这给出了指向目标的大致路径。采样候选位置以当前位置为中心在当前速度方向左右一定角度如90度的扇形区域内生成若干个候选的下一步位置。采样时需考虑最大步长。评估候选位置对每个候选位置计算一个得分。得分公式是项目的精髓一个简化的版本可能是得分 w1 * (到目标距离的减少量) w2 * (与周围行人保持的距离)其中w1和w2是权重用于平衡“走向目标”和“避免碰撞”两个目标。与周围行人的距离通常用排斥力函数计算比如距离越近排斥力呈指数增长得分扣得越多。选择与移动选择得分最高的候选位置更新行人的速度方向并实际移动到该位置通常使用Vector3.MoveTowards或直接赋值位置。更新状态更新行人在网格中的逻辑位置。代码实现技巧在评估函数中避免为每个行人检查场景中所有其他行人O(N²)的复杂度。应该利用网格系统每个行人只检查其所在格子及相邻格子内的其他行人。这需要Grid.cs提供根据网格坐标快速查询该格内所有行人的功能。原项目可能简化了这一点但在大规模模拟中这是性能优化的关键。5. 性能优化与大规模模拟挑战当行人数量从几十增加到几百甚至上千时性能问题会立刻凸显。帧率下降的主要瓶颈在于每帧每个行人的邻居搜索、力计算以及所有行人的GameObject更新开销。5.1 计算性能优化策略空间分区与邻居查询如前所述必须使用网格空间分区。维护一个DictionaryVector2Int, ListPedestrian将行人索引到其所在的网格。这样查找某个格子周围的行人复杂度从O(N)降到O(1)。距离场预计算与复用如果场景中有多个目标点比如多个出口可以预先为每个目标计算好距离场并存储起来。行人只需要根据自己选择的目标切换使用不同的距离场即可。简化物理与碰撞对于人群模拟精确的刚体碰撞开销巨大且不必要。可以完全禁用Unity的物理引擎使用基于网格或代理的简单碰撞检测。例如两个行人占据同一个网格时视为碰撞通过行为逻辑如OSM的排斥力将其推开。使用Jobs System与Burst Compiler这是Unity提供给高性能计算的大杀器。你可以将行人的位置更新、邻居搜索、力计算等逻辑放到IJobParallelFor作业中利用多核并行计算。再结合Burst编译器将C#代码编译成高度优化的本地代码性能提升可达10倍以上。这是将模拟规模推向万级的必经之路。细节层次LOD对于远处的行人可以降低其行为更新的频率比如每3帧更新一次或者使用更简化的行为模型。5.2 渲染性能优化策略GPU Instancing如果所有行人使用同一个网格模型和材质务必开启GPU Instancing。这能在一次绘制调用中渲染成千上万个相同的模型极大降低CPU向GPU提交数据的开销。在行人的材质球上勾选Enable GPU Instancing即可。简化模型与贴图在远处行人可能只是一个像素点。使用LOD Group组件为行人设置多个细节层次的模型距离摄像机越远使用面数越少的模型。动画优化如果行人需要动画避免使用复杂的骨骼动画和每帧更新。可以考虑使用顶点动画贴图VAT或者极简的帧动画。对于大规模人群甚至可以用不同的静止姿态配合颜色变化来制造“动态”错觉。裁剪Culling确保摄像机的视锥体裁剪正常工作。对于俯视角模拟可以很容易地实现自定义的网格裁剪只更新和渲染视野内的行人。5.3 使用ECS架构进行重构进阶对于终极性能追求可以考虑使用Unity的实体组件系统ECS配合DOTS面向数据的技术栈完全重写模拟系统。ECS将数据位置、速度与逻辑移动系统分离并保证数据在内存中连续排列对CPU缓存极其友好。这对于需要处理数万实体且每帧都需要更新的模拟场景是理想选择。不过ECS学习曲线较陡且与传统的GameObject工作流差异较大需要项目初期就做好架构决策。6. 可视化增强与效果打磨基础模拟跑通后我们需要让它看起来更直观、更专业。好的可视化不仅能提升演示效果更是调试的利器。6.1 距离场与路径的可视化原项目的LineDrawer.cs已经提供了绘制网格和轨迹的基础功能。我们可以进一步扩展梯度颜色显示距离场在网格平面的每个格子上根据其距离值的大小用从蓝远到红近的渐变色进行着色。这能让你一眼看清整个场景的“势能”分布。可以通过动态修改网格顶点颜色或使用一张动态生成的纹理来实现。绘制行人的意图线从每个行人身上画一条短线指向其当前计算出的“期望方向”。用另一种颜色画第二条线指向其最终选择的“实际移动方向”。通过对比这两条线你可以直观地看到局部避障行为如何修正了全局路径。绘制压力热图统计每个网格在单位时间内被行人“踩过”的次数并用热力图颜色渲染出来。这能清晰展示人群的密集区域和主要流线对于建筑设计和疏散分析至关重要。6.2 行人外观的多样化与真实感模型与材质多样化不要所有人都用同一个白色小球。准备几个不同颜色、不同体型、不同服装的行人预制体在生成时随机选择。这能立刻提升视觉丰富度。简单动画为行人预制体添加一个非常简单的上下浮动或轻微左右摇摆的动画通过脚本修改Transform.localPosition或Transform.localRotation可以立刻让静止的移动变得有“生命感”。注意动画幅度要小避免喧宾夺主。足迹与尾迹可以为行人添加一个拖尾渲染器Trail Renderer或者每隔几步在脚下实例化一个短暂存在的“足迹”粒子。这能可视化出行人的移动轨迹和历史路径对于分析人流非常有用。6.3 用户交互与控制面板一个强大的控制面板能让你的模拟系统从演示变成工具。利用Unity的UI系统UGUI构建一个面板暴露以下参数全局控制开始、暂停、重置、单步执行。模拟参数行人数量运行时动态增减、模拟速度、OSM模型中的权重w1, w2、行人感知半径、最大速度。环境编辑提供画笔工具让用户可以在运行时点击网格来动态添加或删除障碍物、切换目标点位置。这需要动态触发距离场的重新计算。数据显示实时显示当前帧率、行人总数、平均速度、特定区域密度等统计信息。7. 常见问题排查与调试实录在实际开发中你肯定会遇到各种奇怪的问题。下面是我踩过的一些坑和解决方法。7.1 行人行为异常问题排查问题现象可能原因排查与解决方法行人原地抖动或打转1. 期望方向计算错误导致相邻帧方向剧烈变化。2. 与障碍物或其他行人陷入“死锁”彼此都无法找到可行的移动方向。1.调试绘制可视化每个行人的期望方向和候选位置检查计算逻辑。2.引入随机扰动在评估函数中加入微小的随机项或者在陷入僵局时强制行人执行一个短暂的侧向移动来打破平衡。3.检查距离场确保目标点可达且距离场数值从目标向外单调递增。行人“穿墙”或忽略障碍物1. 世界坐标到网格坐标转换错误导致行人逻辑位置不在障碍物格子上但渲染位置穿模。2. 障碍物信息未正确同步到Pathfinding模块的距离场计算中。1.验证转换函数在场景中放置测试点打印其WorldToCell结果确保与视觉对齐。2.可视化障碍网格在Grid.cs中将标记为障碍的格子用明显颜色如黑色渲染出来确认障碍物设置正确。3.检查距离场初始化确保在GenerateDistanceField中障碍物格子的距离值被设置为一个极大值如int.MaxValue且不会被遍历到。人群在门口或狭窄处形成永久堵塞这是经典的“瓶颈”问题过于简单的局部模型无法解决。1.增加排斥力强度提高行人之间的排斥力权重迫使他们在拥挤时更早地减速和避让。2.引入“耐心”衰减让行人在无法移动时其排斥力随时间略微降低允许更紧密的“挤压”。3.使用更高级的模型考虑引入排队行为或简单的流量控制逻辑。7.2 性能与稳定性问题帧率随人数增加急剧下降首先使用Unity Profiler (Window - Analysis - Profiler) 定位瓶颈。如果Pedestrian.Update占用过高请应用第5节中的优化策略特别是空间分区和Jobs System。如果渲染占用高则检查GPU Instancing是否开启以及模型面数。距离场计算导致游戏卡顿确保距离场计算只在必要时进行如场景加载、目标改变时并且放在协程Coroutine中分帧计算避免单帧卡死。对于超大网格考虑异步计算。行人突然全部消失或位置错乱检查数组越界。在Grid.WorldToCell和访问distanceField[x, y]时务必先检查计算出的网格坐标(x, y)是否在[0, width)和[0, height)范围内。一个简单的Mathf.Clamp可以避免许多诡异的问题。7.3 构建与发布问题WebGL构建后运行缓慢WebGL是单线程的无法利用Jobs System的多线程优势。对于WebGL发布你需要回退到主线程优化的版本并严格控制模拟规模如不超过500人。同时在Player Settings中启用Exceptions为None并积极使用[DllImport(__Internal)]调用C插件来处理密集计算如果可行。移动设备上发热严重移动端GPU和CPU能力有限。必须大幅降低模拟规模和渲染负担。考虑使用更简化的行人代理甚至用贴图方块代替3D模型关闭所有非必要的视觉效果并将模拟帧率锁定在30FPS。这个TUM的人群模拟项目是一个绝佳的起点它清晰地展示了从全局导航到局部避障的完整技术链条。通过深入理解并扩展它你不仅能掌握人群模拟的核心技术更能学到如何将学术算法转化为稳定、高效、可视化的工业级应用。记住所有复杂的系统都是从简单的原型开始的不断迭代、优化和调试才是工程实践的真谛。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价