
最近在做一个室内场景的AR导航项目选型阶段对比了好几套方案最后用了EasyAR 4.0的稀疏空间地图Sparse Spatial Map。这个方案最大的优势是纯视觉定位不需要铺设蓝牙信标、二维码或者反光点手机扫一圈环境、生成地图之后同一个场景里就能反复定位完全够撑起一套可用的AR导航应用。这篇文章把整条技术链路拆开讲清楚从为什么选稀疏空间地图到扫描建图、地图保存、路径渲染、导航指示器怎么写最后附上完整可改的工程代码。如果你是做Unity3D开发、想把AR导航落到室内园区或者展厅场景的同学这篇文章应该能帮你少走不少弯路。内容不吹不黑偏工程实践所有操作我都按项目里的实际开发顺序来写。1. 项目思路与整体设计1.1 稀疏空间地图到底解决了什么问题先理解一个关键区别。传统的2D平面检测比如检测桌面、地面只能告诉你“这里有一块平面”适合在平面上放虚拟模型。但导航场景里人是在三维空间里移动的你要的不是一块平面而是整个空间的几何结构、纹理特征点的分布、以及你的手机相机在这个空间里的精确位置和朝向——这就是SLAM在做的事。EasyAR 4.0的稀疏空间地图通俗点说就是把真实环境的特征点提取出来建成一坨“三维点云地图”。它不是像高精地图那样把墙面、柱子全部三维重建出来而是只用稀疏的特征点描述环境。好处是地图文件小、构建速度快、手机端跑得动而且这套特征点是和真实空间坐标一一对应的定位时通过相机画面里的特征点匹配就能反推当前相机在世界坐标里的6自由度位姿位置加朝向。放到导航场景里这意味着三件事不需要额外硬件手机充当地图构建器和定位器。地图可以持久化保存同一场景扫一次以后随时加载。虚拟路径点、箭头、指示UI可以稳定“粘”在真实空间里视角怎么转都不跑偏。所以稀疏空间地图天然适配AR导航。它不是那种“检测到地面放个杯子”的玩具Demo而是能支撑完整导航体验的地基。1.2 技术选型为什么选了EasyAR而不是其他方案做AR导航的路线其实不少我把当时纠结过的方案列一下方案优点缺点AR Foundation ARCore/ARKit免费文档多Android碎片化部分国产机型对ARCore支持差国内网络环境偶尔抽风二维码/图片标记定位实现简单误差可控摄像头一直要能看到标记导航体验割裂用户心理负担大蓝牙信标/UWB室内定位成熟需要额外部署硬件成本高不是纯视觉方案EasyAR 4.0稀疏空间地图跨平台扫描建图一体离线可用定位精度受环境影响需要调参最终选择EasyAR 4.0有几个很实际的原因。第一它跨Android和iOS一套代码两边跑不用像ARCore那样还要单独处理Google服务依赖。第二稀疏空间地图的“扫一遍生成持久化地图”流程非常贴合导航场景开发工作量明显小。第三EasyAR在国内厂商的中低端机型上也相对稳对测试机不挑。当然它也有代价。稀疏空间地图的定位稳定性依赖环境纹理白墙、玻璃幕墙多的地方容易飘。这个在后面的避坑部分我会详细讲。1.3 导航系统的整体架构项目整体的模块划分我按功能拆成了四块地图构建模块负责扫描环境、生成稀疏空间地图、保存到本地文件。地图加载与定位模块启动时加载指定地图盯着定位状态定位成功后把整个AR世界坐标系锚定到真实空间。路径生成模块读取路径点数据把真实走过的关键位置转成Unity世界坐标下的路径队列并实例化可视化的路径点。导航指示模块用LineRenderer画路线用箭头预置体做转向指示根据当前设备位置和目标路径点不断更新方向提示。这四个模块之间用事件解耦。地图构建完成抛一个MapCreated事件定位成功抛MapLocalized事件路径模块监听到定位成功后开始渲染。这样不会在代码里写出一堆互相牵制的状态判断后面扩展也方便。2. 开发环境准备与基础搭建2.1 Unity工程创建与EasyAR导入我在这个项目里用的Unity版本是2021.3 LTS稳定兼容性好。EasyAR官方推荐用LTS版本尽量避免用最新的预览版因为SDK迭代偶尔会追不上Unity的改动。创建工程后从EasyAR官网下载Unity插件包然后双击导入。导入完成后菜单栏会多出一排EasyAR相关的选项。关键步骤是填入Key在EasyAR官网上注册账号创建一个应用。Android包名必须和应用填写的包名完全一致不然初始化直接失败。把Key拷到Unity里EasyAR的配置面板中。这里有个容易踩的坑打包测试时包名如果带了.debug后缀记得在EasyAR后台也加上这个包名否则真机运行会一直停在初始化失败界面。2.2 场景节点的组织方式场景里的节点结构我建议按下面这种方式搭AR Session空物体AR Camera主相机SparseSpatialMapWorkerFrameFilter挂在相机下PathObjects子物体放所有路径点导航UICanvasSparseSpatialMapWorkerFrameFilter是EasyAR 4.0里的核心组件它同时管理了Builder地图构建器和Localizer定位器。在这个组件内部可以通过面板参数勾选是启用构建模式还是定位模式。路径点不要放在AR Camera下面要放在场景根级别。因为这个虚拟物体需要固定在世界坐标里如果挂在相机下就会跟着相机移动路径点会满天飞。2.3 关键组件与参数说明SparseSpatialMapWorkerFrameFilter面板上常用的几个参数我的调整经验是参数作用建议值Builder是否启用扫描建图构建定位两模式切换Localizer是否启用定位同上FrameSkip每几帧处理一次定位默认1帧率低可调到2地图检测半径匹配范围内的特征点默认即可不过不同SDK版本的参数命名会有些出入最稳的做法是打开官方样例工程先跑一遍不带参数的默认配置确定能出效果再动参数。3. 稀疏空间地图的构建与持久化3.1 建图前的准备与扫描技巧扫描建图是整个AR导航项目里最关键的一步。地图质量直接决定后续定位的稳定性。不要拿手机随便晃一圈就完事我扫坏了几张图之后总结出几个硬性要求扫描路线先围绕场景中心转一圈再扫边角区域。要保证场景每个区域都被覆盖尤其注意拐角、柱子这类有大量特征点的位置。移动速度要慢尽量匀速。移动太快时相机画面容易产生运动模糊特征点提取质量下降地图里会混入大量噪声点。光线环境保持恒定光源。强烈日光和室内灯光交界的区域特征点在一天不同时段差异很大会显著降低重复定位成功率。纹理丰富程度玻璃幕墙、纯白墙面、大面积金属面板这些区域几乎没有可提取的特征点扫描的时候可以重点扫附近的地毯花纹、桌椅边缘、海报等纹理丰富的区域来补充特征。还有一个小技巧在场景里来回走三遍每次路径错开一点。EasyAR会覆盖相同的区域特征点这样生成的地图特征密度更高定位时更容易匹配上。3.2 路径点标记与地图保存建图过程中我会在真实空间里踩出导航路径的关键位置比如从门口到前台、从前台到会议室。每到一个关键点点击屏幕上的“添加路径点”按钮就把当前设备坐标记录下来。这里有个坐标系需要特别注意记录路径点时设备坐标是相对于SparseSpatialMap的地图原点的。地图原点通常是开始扫描时的初始位置。所以这个坐标是可以直接作为虚拟路径点在世界空间的位置使用的。定位成功后AR相机和真实世界对齐路径点在Unity世界坐标里显示的位置就是你在真实空间里标记的位置。扫描完毕后调用Builder的停止接口把地图数据保存为文件。EasyAR 4.0里构建完成后会拿到一个SparseSpatialMap对象地图数据可以按字节流保存到本地。一般我会存到Application.persistentDataPath下可以跨启动持久化。3.3 地图加载与定位初始化定位模式相对简单启动时加载之前保存的地图文件然后把Localizer打开。这时相机会自动开始匹配环境特征匹配成功后会把整张地图原点对齐到真实环境虚拟空间和真实空间就锚定了。定位有一个“收敛”过程不是一启动就瞬间定位成功。设备需要对着周围环境缓慢转一圈让Localizer找到足够的特征点。所以在APP的启动界面我会加一句“请缓慢转动手机扫描周围环境”等定位成功事件触发后再切换进导航界面。4. 核心代码导航路径生成与AR指示4.1 路径数据结构与坐标变换路径点数据我先定义成一个简单的可序列化结构[System.Serializable] public class PathPointData { public int id; public Vector3 position; // 相对于地图原点的位置 public float stayTime 0; // 预留到达此点后的停留时间 }整个路径就是一个ListPathPointData。构建地图时产生的这些点我会序列化成JSON存在本地导航开始时读取。由于这些点的position是相对地图原点的而EasyAR定位成功后Unity世界坐标原点也在这个地图原点上所以加载后直接赋值到场景中即可不需要额外做矩阵变换。4.2 LineRenderer路径渲染与箭头指示器路径线我用LineRenderer画折线把相邻路径点直线连接。要注意LineRenderer的宽度建议设成0.1米左右太宽会遮挡真实环境太窄又看不清。箭头指示器是重点。它要实现的效果是告诉用户接下来朝哪个方向走。具体做法是每隔一段距离我习惯设2米一个在路径线上创建一个箭头模型箭头用LookRotation朝向下一个路径点方向。这样用户跟着箭头走就行不需要看地图。核心逻辑是每一帧去判断当前设备和下一个路径点的距离。小于阈值就切换到下个路径点并重新计算后续箭头的朝向。到达目标点后触发到达事件。4.3 完整代码实现下面这个脚本是导航核心逻辑也是整个项目的主控脚本。它在构建地图时可以记录路径点在定位成功后加载路径点并生成导航UI。我把注释写全一点方便直接改成自己的项目。using System.Collections; using System.Collections.Generic; using System.IO; using UnityEngine; using UnityEngine.UI; using EasyAR.Sense.SparseSpatialMap; public class ARNavigationManager : MonoBehaviour { [Header(EasyAR 稀疏空间地图组件)] public SparseSpatialMapWorkerFrameFilter ssmFilter; [Header(路径点设置)] public GameObject pathPointPrefab; // 路径点可视化预制体小圆柱 public Transform pathObjectsParent; // 路径点父节点 [Header(导航渲染)] public LineRenderer pathLine; // 路径线 public GameObject arrowPrefab; // 箭头预制体 public float arrowSpacing 2.0f; // 箭头间隔米 [Header(UI)] public Button buildMapBtn; // 开始建图按钮 public Button saveMapBtn; // 建图完成保存地图 public Button loadMapBtn; // 加载地图并开始定位 public Text statusText; // 状态提示文本 private SparseSpatialMap currentMap; private bool isBuilding false; // 路径数据 private ListVector3 waypoints new ListVector3(); private ListGameObject pathPointObjects new ListGameObject(); private ListGameObject arrowObjects new ListGameObject(); private int currentTargetIndex 0; // 路径文件路径 private string mapPath Path.Combine(Application.persistentDataPath, office.map); private string pathDataPath Path.Combine(Application.persistentDataPath, pathData.json); void Start() { buildMapBtn.onClick.AddListener(StartBuild); saveMapBtn.onClick.AddListener(SaveCurrentMap); loadMapBtn.onClick.AddListener(LoadMapAndLocate); // 监听定位成功事件 if (ssmFilter ! null ssmFilter.Localizer ! null) { ssmFilter.Localizer.MapLocalized OnMapLocalized; } } void Update() { if (ssmFilter ! null ssmFilter.Localizer ! null) { // 把Localizer计算出的位姿同步给AR相机这一步在部分配置下由组件自动完成 // 如果发现相机和真实世界有偏移可以手动调用这一句 // ssmFilter.Localizer.updateCameraPose(Camera.main.transform); } // 导航状态判断 if (waypoints.Count 1 currentTargetIndex waypoints.Count) { float distance Vector3.Distance(Camera.main.transform.position, waypoints[currentTargetIndex]); if (distance 1.2f) { currentTargetIndex; UpdateArrows(); if (currentTargetIndex waypoints.Count) { statusText.text 到达终点; } } } } // ---------- 建图相关 ---------- public void StartBuild() { if (ssmFilter null || ssmFilter.Builder null) { statusText.text Builder组件不存在; return; } ssmFilter.Localizer.stop(); ssmFilter.Builder.start(); isBuilding true; statusText.text 请缓慢移动手机进行扫描建图; } public void AddPathPoint() { if (!isBuilding) return; Vector3 pos Camera.main.transform.position; waypoints.Add(pos); if (pathPointPrefab ! null) { GameObject go Instantiate(pathPointPrefab, pos, Quaternion.identity, pathObjectsParent); pathPointObjects.Add(go); } statusText.text $已添加路径点{waypoints.Count}; } public void SaveCurrentMap() { if (!isBuilding || ssmFilter.Builder null) return; // 停止构建并获取地图 ssmFilter.Builder.stop(); var map ssmFilter.Builder.map; if (map null) { statusText.text 地图为空请先扫描; return; } // 保存地图数据到本地文件 byte[] mapBytes map.serialize(); File.WriteAllBytes(mapPath, mapBytes); // 保存路径点数据 PathSaveData data new PathSaveData(); data.points waypoints; string json JsonUtility.ToJson(data); File.WriteAllText(pathDataPath, json); isBuilding false; currentMap map; statusText.text 地图和路径数据已保存; } // ---------- 加载与定位 ---------- public void LoadMapAndLocate() { if (!File.Exists(mapPath)) { statusText.text 本地没有找到地图文件请先建图; return; } byte[] mapBytes File.ReadAllBytes(mapPath); SparseSpatialMap loadedMap SparseSpatialMap.deserialize(mapBytes); if (loadedMap null) { statusText.text 地图反序列化失败; return; } // 停止Builder启动Localizer if (ssmFilter.Builder ! null) ssmFilter.Builder.stop(); if (ssmFilter.Localizer ! null) { ssmFilter.Localizer.start(); ssmFilter.Localizer.load(loadedMap); statusText.text 请缓慢转动手机以完成定位; } else { statusText.text Localizer组件不存在; } } private void OnMapLocalized(SparseSpatialMap map) { statusText.text 定位成功开始导航; // 读取路径点数据恢复导航线索 LoadPathData(); // 画路径线 StartCoroutine(GeneratePathVisual()); } private void LoadPathData() { if (!File.Exists(pathDataPath)) return; string json File.ReadAllText(pathDataPath); PathSaveData data JsonUtility.FromJsonPathSaveData(json); waypoints.Clear(); foreach (var go in pathPointObjects) Destroy(go); pathPointObjects.Clear(); // 恢复路径点 if (pathPointPrefab ! null) { foreach (Vector3 pos in data.points) { GameObject go Instantiate(pathPointPrefab, pos, Quaternion.identity, pathObjectsParent); pathPointObjects.Add(go); } } waypoints data.points; currentTargetIndex 0; } private IEnumerator GeneratePathVisual() { // 等一帧让所有实例化完成 yield return null; if (waypoints.Count 2) yield break; // 画线 if (pathLine ! null) { pathLine.positionCount waypoints.Count; for (int i 0; i waypoints.Count; i) { pathLine.SetPosition(i, waypoints[i] Vector3.up * 0.15f); } } // 生成箭头 UpdateArrows(true); } private void UpdateArrows(bool refresh false) { if (refresh) { foreach (var arrow in arrowObjects) Destroy(arrow); arrowObjects.Clear(); } // 从当前目标点开始生成箭头 for (int i currentTargetIndex; i waypoints.Count - 1; i) { Vector3 from waypoints[i]; Vector3 to waypoints[i 1]; float distance Vector3.Distance(to, from); int arrowCount Mathf.Max(1, Mathf.FloorToInt(distance / arrowSpacing)); for (int j 0; j arrowCount; j) { float t (float)(j 1) / (arrowCount 1); Vector3 pos Vector3.Lerp(from, to, t) Vector3.up * 0.2f; Quaternion rot Quaternion.LookRotation(to - from); if (refresh) { GameObject arrow Instantiate(arrowPrefab, pos, rot); arrowObjects.Add(arrow); } } } } [System.Serializable] public class PathSaveData { public ListVector3 points; } }这套代码在设计上有几个值得展开的地方。第一路径点记录用的是当前相机位置。在建图模式下相机位置就是你在真实空间站的位置这样记录下来的坐标是未来定位后能直接对齐的。如果你需要记录的路径点是地面上的某个位置而不是人眼高度可以自己加一个射线检测把坐标改成地面交点。这个根据业务灵活调整。第二箭头的生成不是把所有路径点的箭头一次性生成完。我在UpdateArrows里做了按段生成到达一个路径点后重新生成从当前点出发的箭头这样玩家看到的方向提示永远是“接下来怎么走”不会被后面一大段反向箭头干扰。第三坐标高度补偿。路径线和箭头位置都抬高了0.15米左右理由很简单如果不抬高线和箭头会直接贴在地面上真实场景里会和地面纹理混在一起从斜下方看很不明显。抬高后视觉上更加清晰。4.4 坐标对齐背后的原理这段是很多人容易搞混的地方。定位成功后为什么虚拟路径点会恰好出现在真实空间里的位置EasyAR的Localizer会输出一个相机位姿devicePose这个位姿描述了相机在稀疏空间地图坐标系中的位置和朝向。稀疏空间地图的坐标系原点就是建图初始化的原点也就是你按下“开始建图”按钮时手机所在的位置。所以当你把这个位姿赋给AR相机时Unity场景里的世界坐标系就和稀疏空间地图的坐标系重合了。那虚拟路径点也用的是这个坐标系下的坐标自然就和真实空间一一对应。这个坐标系逻辑理顺后后面再做多点路径规划、绕障、楼层切换都是在这个框架上叠加逻辑不会出现“这里应该能看到箭头显示却跑偏了”这种玄学问题。5. 实际运行中的问题与避坑经验5.1 常见问题与排查速查表我把项目过程中遇到的典型问题整理成了表格按现象、原因、解决办法排列方便你对照排查。现象常见原因排查与解决办法一直提示初始化失败EasyAR Key未正确配置或包名不匹配检查Key是否复制完整确认Unity Player Setting中的包名和后台登记一致定位一直不成功环境光线太暗、纹理太少、相机运动过快切换到光线充足的环境缓慢转动手机贴近有纹理的物体扫描定位成功但路径点严重偏移建图时地图质量差特征点稀疏或噪声多重新建图扫描时放慢速度补充多角度扫描虚拟箭头和真实方向对不上相机位姿没有同步到AR相机确认Localizer在Update中调用了updateCameraPose或检查组件配置地图文件加载后反序列化报错保存和加载时SDK版本不一致确认用同一版本SDK必要时重新建图Android真机上黑屏相机权限未申请在AndroidManifest或运行时申请Camera权限5.2 扫描与定位的实战心得第一建图时千万别省时间。我一开始图快扫了一圈就保存地图结果定位时匹配率极低频繁丢失。后来重新建图来回走了三趟每趟覆盖不同角度定位一下子就稳了。实测下来特征点密集区域的定位漂移基本控制在十几厘米以内导航体验是OK的。第二地图文件不要存StreamingAssets。StreamingAssets在Android上只读而且包体更新麻烦。存到Application.persistentDataPath是最省心的路子。如果后续要做多场景切换可以把地图文件往远程服务器放一份进入场景前下载下来再加载。第三光照变化是最大的敌人。同一个场景白天和晚上建的图换个时间加载定位经常要等更久。项目如果对全天候定位有要求建议分别建两张图白天一张、晚上一张启动时让用户选择或自动根据时间选择。5.3 性能优化与包体控制导航场景里虚拟物体数量其实不多主要耗性能的不是渲染而是定位算法的持续计算。移动端上这个计算量和相机分辨率相关尽量别把EasyAR的相机分辨率调到顶1080p通常足够定位精度使用又能省不少CPU。路径点是静态物体可以合并到一个Mesh里减少Draw Call。箭头的实例化数量控制在合理区间按2米间隔一条50米的路线大约产生24个左右箭头这个量级完全不用做对象池。如果场景超过200米可以考虑用对象池或者只实例化当前段附近的箭头其余隐藏。包体优化方面EasyAR的底层库和模型加起来不算大但要注意别把资源全塞在一个场景里。我把地图扫描、地图加载、路径展示分成三个场景按需加载启动速度和内存占用都好看很多。6. 项目跑通后的几点体会这个项目做下来我最大的感受是AR导航的技术难点并不在“怎么做导航”而在“怎么让虚拟世界稳定地锚定在真实世界里”。路径规划、UI反馈这些都是常规Unity工作真正决定项目成败的是你建的地图质量。所以如果你准备上手这个方案我会建议你在正式开发前先花一两天时间在目标场景里反复做建图、定位的实验把环境的“脾气”摸清楚——哪里特征多、哪里容易丢定位、光照变化有多大。这一步做得越扎实后面代码写起来就越省心。最后再分享一个小技巧如果你的场景比较大路径点特别多建议把路径数据单独存成JSON文件和地图文件分开管理。这样以后微调路径点不需要重新建图改一下JSON再加载就行调试效率会高很多。