资讯详情

Unity场景标注指南:运行时点击任意位置添加可持久化文字备注

📅 2026/10/15 23:38:59 | 华诺云谱 👁 阅读
Unity场景标注指南:运行时点击任意位置添加可持久化文字备注
1. 需求缘起与方案定位做Unity开发久了你会发现一个很常见的需求在场景的某个位置加一个备注写点提示信息。比如模型审查时在问题部位标记“此处碰撞体未对齐”或者给策划看关卡时在某块区域写“这个平台将来放传送门”再或者做数字孪生项目时在对应设备位置标注“三号泵待检修”。需求听着简单真要做得顺手、可维护还是有不少门道的。先说一下标题里的关键词——自定义位置和添加备注文字。前者意味着用户不是通过编辑器手动摆一个对象而是在运行状态下、甚至是在打包后的程序里通过点击场景中的任意位置来创建标注后者说明核心交互是文字不是画线、画框、截图之类的复杂标注。这个定语组合决定了我们做的是一套运行时交互系统而不是静态编辑工具。我个人的建议是第一版不用追求花哨先把“点击场景→获取坐标→生成可编辑文字标记→数据能存能读”这条主链路打通。后面再加拖拽调整、贴地吸附、按距离缩放这些增强功能就顺理成章了。这个功能适合谁适合做项目原型验证的独立开发者、做模型审查工具的团队以及需要非技术人员比如策划、美术、甲方在场景里直接提意见的场景。对这类使用者来说简单可靠远比功能炫酷重要。2. 核心技术选型与原理拆解2.1 三种备选方案的对比运行时的文字标注业内最常用的有下面几条路线方案AGUI或UI层的屏幕坐标标注。把3D坐标通过WorldToScreenPoint转成屏幕坐标文本生成在Canvas下不跟任何3D对象绑定。优点是实现快、性能开销小缺点也明显——一旦旋转视角标注不会跟着物体走必须每帧重新映射坐标到屏幕位置本质上是“浮”在屏幕上的标签和场景本身没有空间依附关系。方案BWorld Space Canvas。在Canvas的Render Mode里选择World Space把它放在某个3D位置文字就变成真正存在于场景中的对象。这是很多标注工具采用的折中方案——既能被相机看到又能参与遮挡关系还能在编辑模式下直接摆放。缺点是Canvas是矩形批量渲染结构如果你场景里有两三百个独立标注用一个个Canvas去承载文本会产生大量DrawCall性能上要有所控制。方案CTextMeshPro 3D文本TextMeshPro字面理解是SDF渲染的文本网格配合Billboard朝向逻辑。这是我最推荐的路线。TMPTextMeshPro的简称本身渲染质量高而且每个标注是一个独立GameObject天然支持批处理和遮挡关系配合自定义材质可以控制远近显隐灵活度最高。可能有朋友会问方案B不是最简单吗确实如果只是做编辑器内验证方案B半小时就能搞定。但注意我们的核心需求是“自定义位置”的运行时添加你的使用者可能是完全不碰Unity编辑器的人场景里动态生成的World Space Canvas会有一堆物体层级管理的问题。方案C一个Prefab搞定所有代码里只维护一个列表干净利落。2.2 关键实现机制为什么需要BillboardTMP文本默认是“躺”在场景里的平面物体。你从正面看很正常但绕到侧面看文字就像一张纸片立在那里斜视时甚至完全看不清。解决思路很朴素把标注物体每一帧都转成正对相机——这就是Billboard广告牌技术的经典用法和粒子系统里的“面向摄像机”是同一个道理。实现时有两点要注意。第一转动的参考系要选好。直接用transform.LookAt(camera.transform)会让物体的正Z轴指向相机如果文本本身的正方向不是Z轴文字会上下颠倒或者左右镜像。稳妥的做法是只处理相机的Y轴旋转避免俯仰角影响让文字永远直立朝上。第二不要每帧对每个标注做完整的四元数运算几百个对象还好上千个时可以考虑只在相机Rotate事件或OnPreCull里刷新减少逐帧开销。2.3 坐标拾取物理射线是地基“自定义位置”的实现本质上靠的是物理射线检测。用户点击屏幕我们往屏幕对应的方向发射一条射线和场景中带碰撞体的对象求交点得到的P点就是标注的世界坐标。逻辑一句话说不完但做起来就那么几行代码真正决定体验的是下面几个细节射线最大距离。场景很大时射线默认射程可能够不到目标。建议给个合理上限或者干脆设成射线无穷远Mathf.Infinity但要注意和你的碰撞体范围配合。命中层过滤。如果地面、墙体、角色、特效都挂在同一个碰撞体系里点人身上还是点地上会直接影响标注坐标的合理性。我的习惯是把“可标注对象”挂到一个专门的Layer上射线只检测这个层如果项目里所有物体都可能被标注那就默认全检测但至少留一个LayerMask参数方便将来排斥掉UI、水面这类区域。空场景补救。射线没打中任何物体时比如地图边界外可以退化为地面高度固定、XZ坐标取点击射线与世界平面的交点。这个逻辑加上之后用户就算点到天上标注也会落到地面上视觉上更自然。3. 数据层设计与持久化方案3.1 备注数据的最小结构标注物在场景里是GameObject但真正需要保存的只有那么几条数据。我建议定义这样一个类[System.Serializable] public class MarkerData { public string id; public Vector3 position; public string content; public long createTime; public Color colorTint; public float fontSize; public string layerTag; }字段解释一下id必须唯一后面增删改查全靠它position存世界坐标content是备注正文createTime用于排序和管理colorTint和fontSize属于显示层面的个性化配置layerTag可留可不留用于区分这个备注关联到哪个业务模块。我见过一些项目把数据直接塞在MonoBehaviour字段里然后依赖场景序列化保存——这种方案在编辑器里好用到了打包运行时就不靠谱了必须走JsonUtility或者LitJson之类的序列化路线。3.2 序列化与存储路径Unity自带的JsonUtility对Vector3、Quaternion这类Unity原生类型支持得很好不需要手动转换成float[]这是最省事的方案。写文件时推荐把路径放在Application.persistentDataPath下而不是StreamingAssets或DataPath。原因很简单persistentDataPath在不同平台Windows、Android、iOS都是可写目录不依赖安装包路径也不受只读权限限制。存储格式上用JSON文本就够了不要为了“性能”去搞二进制。标注数据量再大也就几百条记录JSON可读性好出问题可以直接用文本编辑器打开检查这个好处在实际排查问题的时候太重要了。public static void SaveMarkers(ListMarkerData markers) { MarkerListWrapper wrapper new MarkerListWrapper(); wrapper.items markers; string json JsonUtility.ToJson(wrapper, true); File.WriteAllText(Path.Combine(Application.persistentDataPath, markers.json), json); } public static ListMarkerData LoadMarkers() { string path Path.Combine(Application.persistentDataPath, markers.json); if (!File.Exists(path)) return new ListMarkerData(); string json File.ReadAllText(path); MarkerListWrapper wrapper JsonUtility.FromJsonMarkerListWrapper(json); return wrapper.items ?? new ListMarkerData(); }这里有个坑JsonUtility不支持直接序列化顶层数组所以得套一层MarkerListWrapper类。不套的话反序列化会返回空对象还不报错排查起来很费劲。3.3 数据字典为什么不用List直接存直接拿List存MarkerData也能跑但我在实际项目中吃过亏删除一个标注后要遍历整表、按ID查数据时也要遍历代码写多了特别别扭。后来改成用Dictionarystring, MarkerData做内存态存储增删改查全部走Key性能也好、语义也清晰。但序列化时Dictionary不支持直接JsonUtility所以做法是内存里用Dictionary存盘时转成List读盘时再转回Dictionary。这个转换逻辑封装好之后数据层就很稳定了。4. 完整实践从零实现标注系统4.1 标注对象的Prefab结构在动手写代码之前先在Unity编辑器里把标注的预制体搭出来。结构建议如下根节点MarkerRoot挂MarkerRenderer脚本后面讲坐标放在根节点上。子节点TextObj挂RectTransform会比较奇怪直接用TransformTextMeshPro组件。子节点BackgroundQuad一个Quad或SpriteRenderer用半透明材质做文字底衬增强可读性。子节点PivotIndicator一个小球或箭头模型表示这个标注锚定的3D位置点。注意父节点不要旋转所有朝向逻辑放在MarkerRenderer里处理。Radial的TextMeshPro会有rectTransform的概念如果你在World Space下用它的尺寸单位是米可以通过text.rectTransform.sizeDelta控制框体大小不过这属于进阶技巧第一版用默认设置就行。4.2 核心脚本实现MarkerRenderer这个脚本负责标注物的运行时行为朝向相机、响应点击事件、以及配合数据层刷新内容。using TMPro; using UnityEngine; public class MarkerRenderer : MonoBehaviour { public TextMeshPro textMesh; public GameObject backgroundQuad; public Color normalColor Color.white; private Camera mainCam; private MarkerData data; public void Init(MarkerData markerData, Camera cam) { data markerData; mainCam cam; transform.position markerData.position; textMesh.text markerData.content; textMesh.color markerData.colorTint; textMesh.fontSize markerData.fontSize; gameObject.SetActive(true); } private void LateUpdate() { if (mainCam null) return; // Billboard朝向只绕Y轴转动保持文字正立 Vector3 forward transform.position - mainCam.transform.position; forward.y 0f; if (forward.sqrMagnitude 0.001f) { transform.rotation Quaternion.LookRotation(forward.normalized); } } private void OnMouseDown() { // 点击标注时可以通过事件回调通知管理器进入编辑状态 if (MarkerBus.OnMarkerClicked ! null) MarkerBus.OnMarkerClicked.Invoke(data.id); } }LateUpdate比Update适合处理朝向逻辑因为它在所有相机的移动更新之后执行这样标注不会“慢半拍”。4.3 管理器MarkerManager管理器是整个系统的中枢负责添删改查、序列化、以及把UI输入转换成世界坐标。public class MarkerManager : MonoBehaviour { public Camera targetCamera; public GameObject markerPrefab; public LayerMask hitLayer; public float maxRayDistance 500f; public KeyCode addModeKey KeyCode.LeftShift; private Dictionarystring, MarkerRenderer activeMarkers new Dictionarystring, MarkerRenderer(); private bool addModeEnabled false; void Update() { if (Input.GetKeyDown(addModeKey)) addModeEnabled true; if (Input.GetKeyUp(addModeKey)) addModeEnabled false; if (addModeEnabled Input.GetMouseButtonDown(0)) { TryAddMarkerAtMouse(); } } private void TryAddMarkerAtMouse() { Ray ray targetCamera.ScreenPointToRay(Input.mousePosition); bool hitFound false; Vector3 spawnPos Vector3.zero; RaycastHit hit; if (Physics.Raycast(ray, out hit, maxRayDistance, hitLayer, QueryTriggerInteraction.Ignore)) { hitFound true; spawnPos hit.point; } else { // 射线未命中时让标注落到水平面上可选 Plane groundPlane new Plane(Vector3.up, Vector3.zero); float enter; if (groundPlane.Raycast(ray, out enter)) { hitFound true; spawnPos ray.GetPoint(enter); } } if (!hitFound) return; MarkerData md new MarkerData(); md.id System.Guid.NewGuid().ToString(); md.position spawnPos; md.content ; // 空内容等待用户输入 md.createTime System.DateTime.Now.Ticks; GameObject go Instantiate(markerPrefab, spawnPos, Quaternion.identity, transform); MarkerRenderer renderer go.GetComponentMarkerRenderer(); renderer.Init(md, targetCamera); activeMarkers.Add(md.id, renderer); // 弹出输入框可以是Unity UI的InputField也可以是调用系统弹窗 UIManager.Instance.OpenEditor(md.id); } }几个设计的取舍在这里解释一下。按住Shift进入添加模式是为了避免误点——因为鼠标在场景里要频繁进行其他操作如果每次左键都创建一个标注那用户根本没法正常浏览模型。现在这样只有在按着Shift的情况下点击才会走创建逻辑。第二加一个击中层过滤那些不需要被标注的物件比如纯装饰草、粒子碰撞体就不会干扰拾取了。4.4 UI输入面板与创建流程用户创建标注后弹出一个输入面板这是最常见的交互方式。面板用Unity UGUI做即可挂在Canvas上通过SetActive控制显示隐藏。public class UIManager : MonoBehaviour { public static UIManager Instance; public InputField inputField; public GameObject editorPanel; private string currentMarkerId; void Awake() { Instance this; } public void OpenEditor(string markerId) { currentMarkerId markerId; inputField.text ; editorPanel.SetActive(true); inputField.Select(); inputField.ActivateInputField(); } public void OnConfirm() { MarkerManager.Instance.SetMarkerContent(currentMarkerId, inputField.text); editorPanel.SetActive(false); } public void OnCancel() { MarkerManager.Instance.DeleteMarker(currentMarkerId); editorPanel.SetActive(false); } }有个很值得注意的小细节用户如果取消了输入必须把刚才那个已经生成到场景里的标注删掉。否则就会出现“场景里多了个空标记”的脏状态。我第一版没处理这个问题后来实测中用户取消输入的频率极高空标注一多数据里全是垃圾后面清理起来很麻烦。InputField输入中文没有问题但注意在移动端上需要检查InputField对应的Input Type设置如果是数字类型会弹不出中文键盘。这个问题在真机测试时才暴露开发期PC上完全看不出来。4.5 删除与编辑功能删除逻辑做成右键点击标注或者在选中状态下通过UI按钮触发。右键点击的识别方式在OnMouseOver里监听Input.GetMouseButtonDown(1)。但注意如果场景里同时有多个可点击对象右键事件可能会被别的脚本抢走稳妥做法是在OnMouseDown内判断按钮编号private void OnMouseDown() { if (Input.GetMouseButtonDown(1)) { MarkerManager.Instance.RequestDelete(data.id); } else if (Input.GetMouseButtonDown(0)) { UIManager.Instance.OpenEditorForEdit(data.id); } }编辑的逻辑和新建几乎一样只是打开输入框时要把现有的文字填进去并且确认时走Update而不是Create。不需要单独写一套数据流直接复用SetMarkerContent接口只是从“新建写入”变成“按ID查表写入”。5. 常见问题与排查技巧实录问题一创建出来的标注文字特别糊像贴图没有分辨率。TMP文字默认在多分辨率下需要调整字体贴图的采样模式。出现这个问题的原因通常是没有正确设置TMP的atlas尺寸特别是你用了动态字体时字体会按当前场景的像素比生成贴图放大字体后糊成一团。解决方式在TMP设置里把Atlas Resolution调高比如改成2048或4096会明显改善。另外场景灯光比较暗时可以手动把TMP材质面板里的Local Lighting关掉用全白色材质让文字自发光不受环境光照影响。问题二移动端上射线检测命中位置和手指点击位置对不上。移动端的Input.mousePosition使用习惯上和PC一致但问题是触摸屏上点击的瞬间手指往往还在移动导致最终的屏幕坐标和用户“想要的”位置偏差几个像素。解决思路不要监听GetMouseButtonDown改成监听Touch.phase TouchPhase.Began并且取touch.position而不是Input.mousePosition。另外如果手机上UI和3D场景叠加渲染需要确认你的射线起点坐标不包含UI的尺寸偏差——主要是CanvasScaler的缩放模式导致的问题UI坐标系和屏幕像素坐标系不一致时会偏差。最稳的验证方式是打日志输出屏幕坐标和世界坐标用编辑器模拟器对比一下。问题三标注的Billboard朝向在相机旋转后不刷新。这个问题的根源是代码放在Update里而相机的旋转可能发生在OnPreCull或者别的回调里且Update执行顺序比相机更新早。将朝向逻辑挪到LateUpdate一般能解决百分之八十的情况。另外一个少见的原因是多个相机的存在比如拼接大屏场景里有两台相机各渲染一半标注物只绑定了一个相机另一面的相机看过去没有朝向逻辑文字是歪的。解决办法是对所有需要渲染该标注的相机都做朝向判断或者干脆在脚本里注册到一个全局相机管理器中。问题四序列化后读取中文变成乱码或者直接报错。JsonUtility在Unity的某些旧版本里对中文支持不太好但其实多数情况下问题是编码。File.WriteAllText默认是UTF-8无BOM而有些平台特别是Windows下的一些第三方文本编辑器会以GBK打开导致看起来乱码。实际只要代码层用Encoding.UTF8读写就稳定不确认时可以在读文件后打印字符串长度看是否正常。如果依然是空的检查一下JsonUtility.FromJson是否因为类没有[System.Serializable]而返回空对象这是最常见的原因。问题五批量创建100个标注后帧率肉眼可见地下降。每个标注都是一个独立GameObject都有TMP组件这会导致DrawCall增加。优化思路第一给背景Quad用同一个图集同一材质让它们尽可能合并批次第二TMP的文本内容如果变化不频繁可以尽量复用同一个材质实例避免动态生成新材质第三如果真有上千个标注可以引入LOD策略——距离相机一定距离后把文本显示切换成小分辨率图片或者直接隐藏文字只保留一个点标记。问题六用户点击标注时总触发到地面点击两个操作冲突了。这是事件系统常见的坑。UGUI的EventSystem和3D物体的OnMouseDown会同时响应如果地面走了物理射线而UI也抢了点击事件就会冲突。解决方案在EventSystem的PhysicsRaycaster组件上设置一个单独的UILayer让3D拾取走另一个LayerMask两者互不干扰。简单粗暴但好用在标注的点击处理里加一个Delay判断如果按钮状态为“刚刚点击过UI”就跳过本次响应也能解决大部分问题。问题七坐标存下来后再次加载位置整体偏移了。这是个隐蔽的坑。如果你在制作时用运行时生成的地图而地图有随机生成逻辑比如每次启动地形微调那保存的坐标是这批地形的坐标下次加载地形变化了自然对不上。解决办法要么是固化场景种子要么是把坐标存成相对某个锚点的偏移量加载时再相对锚点还原。锚点坐标可以在保存时一并写进序列化文件加载时先实例化锚点物体再根据偏移量计算每个标注的位置。问题八编辑器模式正常打包到Android后点击没反应。大概率是PhysicsRaycaster或碰撞体的Layer没打包进去或者在移动端默认的touch input没有开启。最简单确认手段打包前在Player Settings里检查Active Input Handling是否设成了Input Manager新版项目默认是Input System Package两者API不同。如果项目同时用了两套输入系统可能导致Input.GetMouseButtonDown一直返回false。这种问题开发期不容易暴露都是在真机上踩雷。6. 数据可视化与进阶特性扩展核心链路打通之后后续的扩展方向其实非常明确。我建议优先加这几个特性距离自适应显示。标注的内容在近处很清晰远处小得看不清。可以在MarkerRenderer里根据相机距离动态调整字体大小距离越远字号越小这个由观察者自然感知但为了防止极小字在远距离产生“噪点”视觉配合一个显隐开关——距离超过阈值时隐藏文字只保留PivotIndicator小球。实现起来非常简单private void UpdateVisibility() { float dist Vector3.Distance(mainCam.transform.position, transform.position); bool visible dist distThreshold; textMesh.gameObject.SetActive(visible); backgroundQuad.SetActive(visible); }这个功能在实际使用时价值极高因为标注多了以后远看近看都要舒服否则满屏文字根本没法看。拖拽微调。允许用户在创建后拖拽标注到更精确的位置。实现方式可以通过鼠标拖拽改变MarkerRoot的坐标但注意要限制拖动的偏移范围防止用户把标注拉到意想不到的地方。UI上可以加一个“锁定位置”的按钮锁定后拖拽失效防止误操作。归类管理与筛选。给标注加一个Category字段UI上提供几个过滤按钮比如“只看设备类”或者“只看问题类”。这对标注多的项目非常实用否则满屏都是标签谁也看不清。数据层和UI层都不复杂加就是了。批量导出报告。标注本身就是数据资产。可以一键导出所有标注内容为CSV或者JSON交给其他人做问题跟进。这个功能做起来成本很低但往往能带来意外的好评因为对非技术人员来说在一个文件里看到“问题位置描述”比在编辑器里一个一个看高效得多。贴地高度修正。射线检测到目标点后可以把Y坐标稍微抬高一点避免标注文字直接嵌入模型表面。一般抬高0.3米到0.5米效果就不错。这个阈值需要根据模型尺寸调节建议做成公开参数方便不同项目微调。7. 工程化组件结构总结为了让读者能直接上手我把整个系统按模块列清楚。模块核心类职责数据层MarkerData / MarkerListWrapper / MarkerDataStore定义数据模型、序列化与反序列化、读写文件交互层MarkerRenderer / MarkerManager射线拾取、标注创建、删除、内容更新、朝向刷新UI层UIManager / MarkerEditorPanel输入框管理、编辑面板开关、内容确认与取消事件层MarkerBus 静态事件标注点击、创建、删除、内容变更的消息广播扩展层MarkerFilter / MarkerLOD / ExportTool分类筛选、距离显示控制、批量导出事件层是我后加进去的一开始觉得一个需求而已何必要搞一套事件系统。实际用下来发现标注被点击之后可能UI要刷新列表、可能数据层要保存、可能某个业务脚本要响应大家各自监听就行了不用在MarkerManager里堆一堆耦合代码。所以这个事件总线不是可有可无是真正能帮你少踩坑。8. 最终工程运行效果与验收清单整套系统跑起来后用户的典型操作流程是按住Shift点击场景→输入框弹出→输入文字确认→场景里出现一个固定在点击位置的文字标注左键点它可以再次编辑右键点它删除关闭程序再打开标注还在原来的位置。这就是一个完整闭环。验收时建议按这个清单逐项检查在新场景创建10个标注确认全部能正常显示并朝向相机。修改其中一个内容确认重新加载后内容还是修改后的。删除一个标注确认数据文件里对应记录也被清掉。在空区域点击没有碰撞体确认标注落到地面上而不是飞在半空中。把相机旋转到标注的正后方确认文字是正对相机的。批量创建50个标注观察帧率和DrawCall变化情况。多平台测试一次输入中文的稳定性PC端和移动端都要测。确定数据文件路径手动打开JSON文件确认内容可读。每一项过了这套系统基本就算“可以在真实项目里用”了。说实话做这种基础功能类工具真正考验你的不是功能本身而是边界情况处理得细不细。取消输入时的脏标注、远程射线的坐标失真、中文编码的无标签坑、输入系统不匹配这类问题只有踩过才会记得住。我写这篇文章的目的就是把这一圈坑替你们趟一遍少走点弯路。剩下的路径你都看到了按着做很快就能跑起来跑起来之后再根据自己的项目特点加你想要的花活。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑