给三维场景加文字备注,看起来好像就是“用鼠标点一下、放个文字”的事情,结果我真正动手做的时候,前前后后踩了不少坑。这个需求来自一个建筑可视化Demo:演示人员要在场景任意位置留下备注,比如“这面墙要改颜色”“这里要放一台设备”,之后还能拖动位置、修改文字,关了场景再打开备注也得还在。需求本身一句话能说清,但落到Unity里,背后是坐标换算、交互状态、字体渲染、数据持久化一整条链路。如果你也在做“Unity实现自定义位置添加备注(文字)功能”,这篇文章把我的完整思路和踩过的坑都写下来,可以直接照着实现。
1. 锁定方案:3D空间节点比UI浮层更稳
1.1 三种常见实现路线的取舍
动手写代码之前,我一直纠结一个核心问题:这个“备注”到底该以什么形态存在于场景里。我先后对比了三种方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| UI覆盖层(Screen Space Canvas) | 文字清晰、永远不被模型遮挡 | 场景旋转后标注不会跟随三维位置,保存坐标时语义混乱 |
| 世界空间3D节点 + TextMeshPro | 位置就是世界坐标,天然贴合三维场景 | 需要处理字体内存、朝向、排序、被遮挡等问题 |
| 在模型表面动态生成标记 | 跟模型绑定紧密、锚定准确 | 只能加在有Collider的模型上,位置受表面限制 |
第一种方案最容易被新手选上,因为它最“省事”:鼠标点击后往UI里加一个Text,代码量最少。但实际用起来非常别扭——客户旋转相机之后,那些备注还停在屏幕上原来的坐标点,跟模型对应的位置完全对不上,客户很快就问了“我这个备注到底是标在哪里的”。如果强行做坐标换算,又等于自己维护一套从屏幕坐标到世界坐标的投影关系,反而是重复造轮子。
第二种方案才是这个需求的正解:让备注本身成为场景里的一个3D物体,它的transform.position就是标注位置,相机怎么转它都留在原地。配合TextMeshPro,既能保证文字可读性,也能把文字当作真实世界里的一个物体来操作。
1.2 最终选型与整体架构
我最终选了“3D节点 + TextMeshPro”路线,并把这功能拆成三个明确模块:
- NotePlacer:负责鼠标点击检测、屏幕坐标到世界坐标换算、实例化新的备注节点。
- NoteMarker:挂在每个备注节点上,负责显示文字、响应选中、拖动、编辑、删除。
- NoteDataManager:负责备注数据的序列化保存和场景启动时的还原。
一个备注节点本身的层级大致是这样:
NoteRoot(挂NoteMarker脚本,带一个Sphere Collider用于点击检测) ├── GroundPin(空物体,标记地面接触点) └── TextContainer └── Label(TextMeshPro组件,显示文字)之所以把GroundPin和文字分层,是因为实际使用中文字需要固定悬浮在地面上方一定高度,而拖拽和点击检测只需要关心地面接触点。这样拖动时文字不会因为相机视角变化而晃来晃去。
提示:如果你的场景已经有成熟的UIManager,也不用担心这方案冲突。备注节点完全是场景里的GameObject,不依赖任何Canvas,和UI系统互不干扰。
2. 点击选点:从屏幕坐标到世界坐标的换算链路
2.1 基础射线检测的写法与UI穿透判断
点击选点的核心是“把屏幕上的一个点变成世界空间里的一个位置”,最直接的办法是用Camera.ScreenPointToRay发射一条射线,和场景里的Collider求交。基础代码如下:
using UnityEngine; public class NotePlacer : MonoBehaviour { [SerializeField] private Camera targetCamera; [SerializeField] private GameObject notePrefab; void Update() { if (Input.GetMouseButtonDown(0)) { if (IsPointerOverUI()) return; Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 500f)) { CreateNoteAt(hit.point); } } } private bool IsPointerOverUI() { if (UnityEngine.EventSystems.EventSystem.current == null) return false; return UnityEngine.EventSystems.EventSystem.current.IsPointerOverGameObject(); } }这里有个特别容易忽略的细节:UI穿透判断。如果你场景里同时有操作面板、按钮或者任何UGUI元素,不判断EventSystem的话,用户在点按钮的时候也会在背后加一个备注,体验直接崩了。IsPointerOverGameObject()在新版Input System下可能需要传pointerId参数,桌面端一般默认就行,移动端触摸要注意。
射线检测的maxDistance我给的是500,实际项目里你可以根据相机远裁剪面调整,或者直接用float.PositiveInfinity。但要注意:如果射线打中的是场景背后的墙体而不是地面,那你得到的坐标就不在预期范围内,这取决于你的场景是否有完整的Collider,后面第六节我会细说。
2.2 落到标准平面上:Plane交点法
很多时候场景里的模型并没有Collider,或者它们是从外部导入的复杂FBX,给每个Mesh都加Collider性能太差。这时候就需要一个备选方案:把“自定义位置”放宽为“落到某个标准平面上”,最常见的平面就是y=0的地面,也可以是当前摄像机视线与某个水平面的交点。
用Unity的Plane结构做射线求交很简洁:
public bool TryGetWorldPointOnPlane(Vector2 screenPos, float planeHeight, out Vector3 point) { Plane groundPlane = new Plane(Vector3.up, new Vector3(0f, planeHeight, 0f)); Ray ray = targetCamera.ScreenPointToRay(screenPos); if (groundPlane.Raycast(ray, out float enter)) { point = ray.GetPoint(enter); return true; } point = Vector3.zero; return false; }这段代码里Plane.Raycast返回一个enter距离值,然后ray.GetPoint(enter)就得到世界坐标。注意Plane的法线我用的是Vector3.up,这是标准地面朝向。如果你的项目是横版或者墙面投影,法线就要改成对应的方向。
我在实际项目里采用“混合策略”:先Physics.Raycast,如果打中了Collider,就用命中的世界坐标;如果什么都没打中,再用Plane求交落到地面上。这样模型表面和空地面都能加备注,操作手感最自然。
2.3 坐标换算中的常见偏差来源
坐标换算这块容易出三类偏差,我逐个说:
第一,目标平面不明确。同一屏幕位置在不同深度的射线落点完全不同,你必须想清楚备注是加在哪个平面/物体上。很多“点到的地方和鼠标对不上”其实是射线命中了场景里的其他小物体。
第二,相机模式。正交相机和透视相机下ScreenPointToRay的射线行为不同,正交相机的射线是平行的,透视相机是从相机位置发出的发散射线。如果你的项目用正交相机做俯视场景,Plane求交依然可靠,但Physics.Raycast命中顺序可能和视觉不符。
第三,Layer分层。建议把所有可添加备注的物体放在一个专门的Layer里,比如“RemovableSurface”,射线检测用LayerMask只检测这个层:
[SerializeField] private LayerMask targetLayers; Physics.Raycast(ray, out hit, 500f, targetLayers);这样能避免点到人物、粒子特效或其他不相关物体时误触加备注。
3. 备注节点的搭建:文字、锚点与面向相机的技巧
3.1 节点层级设计与预制体组织
我把备注做成一个Prefab,而不是每次运行时动态创建一组空物体。Prefab的好处是,你在编辑器的Inspector里就能调好所有材质、字体、尺寸和默认位置,运行时只需要实例化。
Prefab结构再强调一次:
NoteRoot ├── GroundPin(空物体,带 Sphere Collider,radius 0.3 左右) └── TextContainer └── Label(TextMeshPro)这样设计有一个额外好处:GroundPin的碰撞体就是鼠标选中和拖拽的检测器,文字本身不参与物理检测。你完全可以把Sphere Collider想象成这个备注在三维空间里的“手柄”,鼠标必须点到手柄才能拖动。
实例化的时候,我把Prefab的根部位置设置成点击得到的坐标,然后让GroundPin保持在根部位置,TextContainer则向上抬一定高度:
public void CreateNoteAt(Vector3 worldPos) { GameObject go = Instantiate(notePrefab, worldPos, Quaternion.identity, notesRoot); NoteMarker marker = go.GetComponent<NoteMarker>(); marker.Setup("双击编辑备注", 1.5f); }高度1.5米是我在建筑可视化项目里试出来的比较舒服的值——人眼注视场景时,1.5米高的文字正好在视平线附近,不会贴地导致遮挡问题。
3.2 TextMeshPro的中文字体与排序设置
这个环节掉坑最多。Unity自带的TextMeshPro默认字体资源Arial SDF是不包含中文字符的,直接往TMP里写“这面墙要改颜色”,运行起来全是方框或者空白。解决办法是先准备一个中文字体文件,比如思源黑体或微软雅黑,然后在Unity菜单Window -> TextMeshPro -> Font Asset Creator里把字体ttf拖进去,注意设置:
- Source Font File:选择你的中文字体。
- Atlas Resolution:建议2048起步,文字多就4096。
- Character Set:如果只需要常用汉字,选Dynamic或Custom Range,能省不少图集内存。
生成后得到一个FontAsset,把它拖给TextMeshPro组件的Font Asset字段,中文就能正常显示了。
还有一个渲染排序坑:TextMeshPro本质上是MeshRenderer,它和普通模型之间遵守Shader的渲染队列和ZTest。如果你希望备注文字始终显示在模型前面,而不是被墙挡住,我用的方法是单独建一个“标注专用Shader变体”,把ZTest改成Always。但这里我必须提醒你:全部备注都“穿越”墙体显示,可能会遮住客户需要观察的细节,所以我把这个效果做成了界面上的开关,默认“跟随ZTest”,需要时再开启“备注置顶”。
3.3 Billboard朝向的处理
文字放在世界空间里,如果它固定不转,相机转到侧面时会发现文字是“侧着”的,根本没法读。必须让标注始终面向相机,这叫Billboard效果。我不用Unity的BillboardRenderer组件,因为它会连模型一起转向,只需要控制文字容器:
using UnityEngine; public class BillboardFacer : MonoBehaviour { [SerializeField] private Transform targetCamera; void LateUpdate() { if (targetCamera == null) return; Vector3 lookPoint = targetCamera.position; lookPoint.y = transform.position.y; if (Vector3.Distance(lookPoint, transform.position) > 0.001f) transform.LookAt(lookPoint); } }注意我先把目标点的y改成跟文字一样的y,再LookAt,这样文字只会绕Y轴水平旋转,不会因为相机抬头或低头而倒过来。如果少了这一步,相机从正上方俯视时,文字会“躺”在地上,完全没法读。
4. 交互闭环:选中、拖动、编辑与删除的完整状态流
4.1 交互状态机设计
只做到“点击加备注”远远不够,真实客户一定会要求“改字、换位置、删掉不要的”。我一开始直接往NoteMarker的Update里写各种if判断,功能越多逻辑越乱。后来改成状态机,清爽很多:
public enum NoteState { Idle, // 常规状态,等待选中 Dragging, // 正在拖动位置 Editing // 正在编辑文字 }状态转换很简单:
| 当前状态 | 输入事件 | 下一状态 | 触发动作 |
|---|---|---|---|
| Idle | 鼠标按下并命中Marker | Dragging | 记录高度,开始拖拽 |
| Dragging | 鼠标抬起 | Idle | 保存新坐标 |
| Dragging | 点击删除按钮 | Idle | 销毁节点 |
| Idle | 双击或点击编辑按钮 | Editing | 呼出输入面板 |
| Editing | 确认提交 | Idle | 设置文字内容 |
这套状态机用一个enum加switch就能实现,关键是把每个状态的Update逻辑分开,别揉在一起。
4.2 拖动时保持合理高度的算法
拖拽的核心是:鼠标在屏幕移动时,把新的世界坐标算出来,但文字高度要保持不变,或者保持在用户设定的高度。我的做法是,进入Dragging时先取当前GroundPin的y值,然后用这个y值临时构造一个水平Plane,在Update里跟鼠标射线求交:
[SerializeField] private NoteState state = NoteState.Idle; private float dragHeight; private Plane dragPlane; void StartDrag() { state = NoteState.Dragging; dragHeight = transform.position.y; dragPlane = new Plane(Vector3.up, new Vector3(0f, dragHeight, 0f)); } void Update() { if (state != NoteState.Dragging) return; Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); if (dragPlane.Raycast(ray, out float enter)) { Vector3 point = ray.GetPoint(enter); transform.position = new Vector3(point.x, dragHeight, point.z); } }这样可以保证备注在拖动过程中不会忽高忽低。唯一要注意的是俯视角太极端的时候,射线与水平面夹角太小,Plane.Raycast的结果会非常远甚至无解,所以在早版本里我加了保护:如果相机俯仰角超过70度,直接把拖拽高度锁在Plane上,但交互会变得不跟手。实际项目里,客户主要是在正常透视视角下操作,这个问题遇到概率不大,遇到时改用“投射到地面上再加固定偏移”也能凑合。
4.3 编辑与删除的接入方式
编辑和删除我做了两种接入方式。第一种是屏幕侧边栏:点击选中备注后,屏幕右侧出现一个编辑面板,包含输入框、确认和删除按钮,适合编辑器里操作。第二种是WorldSpace小面板:在文字旁边长出一块背景板,板上放两个小按钮,适合在三维视口里直接操作。
我最终推荐WorldSpace小面板,因为客户在展示时不希望看到太多传统UI,备注旁边有伴随按钮更直观。做法是在TextContainer下挂一个WorldSpace Canvas,里面一个背景Image和两个按钮,Canvas的事件相机指定为主相机,GraphicRaycaster会自动处理点击。
提示:WorldSpace Canvas的EventSystem和其他Canvas共享,不用额外创建。但要记得给Canvas设置CanvasScaler,否则按钮在远处会非常小,点都点不到。
5. 数据持久化:场景重启后备注不能丢
5.1 用JsonUtility做序列化的注意事项
客户的需求里明确提到“关了场景再打开备注还在”,这就必须数据持久化。Unity自带的JsonUtility序列化方便,但有三个坑要注意:
- 不能直接序列化Dictionary,改用List。
- 类必须加[Serializable],字段建议public。
- Vector3、Color这些Unity基础类型是支持的,直接作为字段就行。
我的数据类长这样:
[Serializable] public class NoteData { public string content; public Vector3 position; public float labelHeight; public Color color; } [Serializable] public class NoteDataList { public List<NoteData> notes = new List<NoteData>(); }这里content存文字内容,position存GroundPin的世界坐标,labelHeight存文字悬浮高度,color存文字颜色,这样一个类就够还原整个备注。
5.2 保存与还原的时间节点
保存的触发时机我做了两种:一是手动点击“保存标注”按钮,适合客户确认完再存;二是OnApplicationQuit自动保存,防止手滑关掉场景丢数据。
保存代码很简单:
using System.IO; using UnityEngine; public static class NoteSaveUtility { private static string GetPath() { return Path.Combine(Application.persistentDataPath, "notes.json"); } public static void Save(List<NoteMarker> markers) { NoteDataList list = new NoteDataList(); foreach (var marker in markers) { if (marker == null) continue; list.notes.Add(marker.ToData()); } File.WriteAllText(GetPath(), JsonUtility.ToJson(list, true)); } public static NoteDataList Load() { string path = GetPath(); if (!File.Exists(path)) return new NoteDataList(); string json = File.ReadAllText(path); return JsonUtility.FromJson<NoteDataList>(json); } }还原的时候,在场景入口(可以是启动场景的某个空物体脚本)调用Load,遍历notes逐个实例化Prefab并设置坐标和文字。这里注意一定要用Application.persistentDataPath,不要用Application.dataPath,后者在打包后是只读目录,写入会直接抛异常。
5.3 多场景迁移时的数据归属
如果你的项目涉及多场景叠加或者场景切换,备注数据不能跟某个场景对象走,否则切场景就没了。我的做法是让NoteDataManager变成场景启动时的单例,标记为DontDestroyOnLoad,并在场景切换后重新实例化所有备注。
如果是一些大型项目里不同楼层对应不同场景,我还会在NoteData里加一个sceneId字段,保存时按sceneId分类,加载时只还原当前场景的备注,切场景时重新读一遍。这个字段不复杂,但能避免所有楼层的备注挤在一起。
6. 实战中踩过的坑与规避方案
6.1 RenderTexture下点击偏移
有一次我把场景渲染到RawImage上做一个俯视小地图,同时想在小地图上点选加备注。这时IfInput.mousePosition的屏幕坐标根本不对,因为它直接对应的是主相机全屏,而RawImage在UI里只是一个小区域。我一开始没做换算,点哪里都不准。
正确的做法是用UnityEngine.EventSystems里的RectTransformUtility.ScreenPointToLocalPointInRectangle先把屏幕坐标转换到RawImage的局部坐标,然后按RawImage实际显示尺寸和RenderTexture分辨率的比例做一个映射,最后用渲染俯视画面的相机去发射射线。这一步逻辑不复杂,但很容易漏。如果你不打算在小地图上点选,直接忽略这个坑,但主相机如果输出到RenderTexture再显示,同样要小心。
6.2 模型没有Collider导致点击失效
Revit、SketchUp导出的FBX模型经常没有默认Collider,Physics.Raycast打过去命中不了任何东西。如果你不去管理这个,客户在模型表面点击加备注会毫无反应,体验非常不好。
我的规避方案就是前面提到的混合策略:先Raycast检测Collider,命中就用;没有命中再用Plane求交。同时,我给了模型一个可勾选的“自动加Collider”妥协方案——如果某些物体必须表面加备注,运行时为它的MeshRenderer动态挂一个MeshCollider,但只给静态物体挂,避免动态物体物理性能开销。
6.3 文字缩放失真的处理
我最初为了让远处文字“看起来大一点”,直接把TextMeshPro的transform.scale调大,结果放大到一定程度字形边缘发虚,缩小后更模糊。原因是TMP的字形图集分辨率和缩放比例的配合出了问题。
正确做法是固定一个合理的世界空间尺寸,比如Label的scale始终保持0.01至0.02,然后用fontSize控制视觉大小。如果确实需要场景级别大范围缩放,就修改fontSize而不是transform.scale。Unity社区里那句“只动fontSize,别动Scale”确实是对的。需要大尺寸文字时,我会重新生成Atlas Resolution更高的FontAsset,效果立竿见影。
最后再分享一个我个人的实际体会:这个功能看着小,但它把Unity开发里很多关键点都串起来了——坐标换算、碰撞检测、UI事件分流、动态字体、序列化存储,每一个单独拿出来都能写一篇坑文。你在做的时候不要急于把所有交互都塞进一个脚本,拆成NotePlacer、NoteMarker、NoteDataManager三个模块后,排查问题会轻松很多。如果后续想继续扩展,可以考虑加入Undo/Redo、批量选中、导出为Excel清单,甚至把备注数据流交给网络服务做多人协同标注,这些方向都是在现有架构上自然生长出来的。