☰
Unity场景标注指南:运行时点击任意位置添加可持久化文字备注
2026/10/15 23:38:47 网站建设 项目流程

1. 需求缘起与方案定位

做Unity开发久了,你会发现一个很常见的需求:在场景的某个位置加一个备注,写点提示信息。比如模型审查时在问题部位标记“此处碰撞体未对齐”,或者给策划看关卡时在某块区域写“这个平台将来放传送门”,再或者做数字孪生项目时在对应设备位置标注“三号泵待检修”。需求听着简单,真要做得顺手、可维护,还是有不少门道的。

先说一下标题里的关键词——自定义位置和添加备注(文字)。前者意味着用户不是通过编辑器手动摆一个对象,而是在运行状态下、甚至是在打包后的程序里,通过点击场景中的任意位置来创建标注;后者说明核心交互是文字,不是画线、画框、截图之类的复杂标注。这个定语组合决定了我们做的是一套运行时交互系统,而不是静态编辑工具。

我个人的建议是,第一版不用追求花哨,先把“点击场景→获取坐标→生成可编辑文字标记→数据能存能读”这条主链路打通。后面再加拖拽调整、贴地吸附、按距离缩放这些增强功能就顺理成章了。这个功能适合谁?适合做项目原型验证的独立开发者、做模型审查工具的团队,以及需要非技术人员(比如策划、美术、甲方)在场景里直接提意见的场景。对这类使用者来说,简单可靠远比功能炫酷重要。

2. 核心技术选型与原理拆解

2.1 三种备选方案的对比

运行时的文字标注,业内最常用的有下面几条路线:

方案A:GUI或UI层的屏幕坐标标注。把3D坐标通过WorldToScreenPoint转成屏幕坐标,文本生成在Canvas下,不跟任何3D对象绑定。优点是实现快、性能开销小;缺点也明显——一旦旋转视角,标注不会跟着物体走,必须每帧重新映射坐标到屏幕位置,本质上是“浮”在屏幕上的标签,和场景本身没有空间依附关系。

方案B:World Space Canvas。在Canvas的Render Mode里选择World Space,把它放在某个3D位置,文字就变成真正存在于场景中的对象。这是很多标注工具采用的折中方案——既能被相机看到,又能参与遮挡关系,还能在编辑模式下直接摆放。缺点是Canvas是矩形批量渲染结构,如果你场景里有两三百个独立标注,用一个个Canvas去承载文本会产生大量DrawCall,性能上要有所控制。

方案C:TextMeshPro 3D文本(TextMeshPro字面理解是SDF渲染的文本网格),配合Billboard朝向逻辑。这是我最推荐的路线。TMP(TextMeshPro的简称)本身渲染质量高,而且每个标注是一个独立GameObject,天然支持批处理和遮挡关系,配合自定义材质可以控制远近显隐,灵活度最高。

可能有朋友会问:方案B不是最简单吗?确实,如果只是做编辑器内验证,方案B半小时就能搞定。但注意我们的核心需求是“自定义位置”的运行时添加,你的使用者可能是完全不碰Unity编辑器的人,场景里动态生成的World Space Canvas会有一堆物体层级管理的问题。方案C一个Prefab搞定所有,代码里只维护一个列表,干净利落。

2.2 关键实现机制:为什么需要Billboard

TMP文本默认是“躺”在场景里的平面物体。你从正面看很正常,但绕到侧面看,文字就像一张纸片立在那里,斜视时甚至完全看不清。解决思路很朴素:把标注物体每一帧都转成正对相机——这就是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(List<MarkerData> 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 List<MarkerData> LoadMarkers() { string path = Path.Combine(Application.persistentDataPath, "markers.json"); if (!File.Exists(path)) return new List<MarkerData>(); string json = File.ReadAllText(path); MarkerListWrapper wrapper = JsonUtility.FromJson<MarkerListWrapper>(json); return wrapper.items ?? new List<MarkerData>(); }

这里有个坑:JsonUtility不支持直接序列化顶层数组,所以得套一层MarkerListWrapper类。不套的话,反序列化会返回空对象,还不报错,排查起来很费劲。

3.3 数据字典:为什么不用List直接存

直接拿List存MarkerData也能跑,但我在实际项目中吃过亏:删除一个标注后要遍历整表、按ID查数据时也要遍历,代码写多了特别别扭。后来改成用Dictionary<string, MarkerData>做内存态存储,增删改查全部走Key,性能也好、语义也清晰。但序列化时Dictionary不支持直接JsonUtility,所以做法是:内存里用Dictionary,存盘时转成List,读盘时再转回Dictionary。这个转换逻辑封装好之后,数据层就很稳定了。

4. 完整实践:从零实现标注系统

4.1 标注对象的Prefab结构

在动手写代码之前,先在Unity编辑器里把标注的预制体搭出来。结构建议如下:

  • 根节点MarkerRoot:挂MarkerRenderer脚本(后面讲),坐标放在根节点上。
    • 子节点TextObj:挂RectTransform会比较奇怪,直接用Transform+TextMeshPro组件。
    • 子节点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 Dictionary<string, MarkerRenderer> activeMarkers = new Dictionary<string, 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.GetComponent<MarkerRenderer>(); 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点击场景→输入框弹出→输入文字确认→场景里出现一个固定在点击位置的文字标注;左键点它可以再次编辑;右键点它删除;关闭程序再打开,标注还在原来的位置。这就是一个完整闭环。

验收时建议按这个清单逐项检查:

  1. 在新场景创建10个标注,确认全部能正常显示并朝向相机。
  2. 修改其中一个内容,确认重新加载后内容还是修改后的。
  3. 删除一个标注,确认数据文件里对应记录也被清掉。
  4. 在空区域点击(没有碰撞体),确认标注落到地面上而不是飞在半空中。
  5. 把相机旋转到标注的正后方,确认文字是正对相机的。
  6. 批量创建50个标注,观察帧率和DrawCall变化情况。
  7. 多平台测试一次输入中文的稳定性(PC端和移动端都要测)。
  8. 确定数据文件路径,手动打开JSON文件确认内容可读。

每一项过了,这套系统基本就算“可以在真实项目里用”了。

说实话,做这种基础功能类工具,真正考验你的不是功能本身,而是边界情况处理得细不细。取消输入时的脏标注、远程射线的坐标失真、中文编码的无标签坑、输入系统不匹配这类问题,只有踩过才会记得住。我写这篇文章的目的就是把这一圈坑替你们趟一遍,少走点弯路。剩下的路径你都看到了,按着做,很快就能跑起来;跑起来之后,再根据自己的项目特点加你想要的花活。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询