Unity实现3D场景自定义位置文字备注:坐标换算与交互全流程

发布时间:2026/10/15 5:07:27
Unity实现3D场景自定义位置文字备注:坐标换算与交互全流程 给三维场景加文字备注看起来好像就是“用鼠标点一下、放个文字”的事情结果我真正动手做的时候前前后后踩了不少坑。这个需求来自一个建筑可视化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 └── LabelTextMeshPro组件显示文字之所以把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性能太差。这时候就需要一个备选方案把“自定义位置”放宽为“落到某个标准平面上”最常见的平面就是y0的地面也可以是当前摄像机视线与某个水平面的交点。用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 Colliderradius 0.3 左右 └── TextContainer └── LabelTextMeshPro这样设计有一个额外好处GroundPin的碰撞体就是鼠标选中和拖拽的检测器文字本身不参与物理检测。你完全可以把Sphere Collider想象成这个备注在三维空间里的“手柄”鼠标必须点到手柄才能拖动。实例化的时候我把Prefab的根部位置设置成点击得到的坐标然后让GroundPin保持在根部位置TextContainer则向上抬一定高度public void CreateNoteAt(Vector3 worldPos) { GameObject go Instantiate(notePrefab, worldPos, Quaternion.identity, notesRoot); NoteMarker marker go.GetComponentNoteMarker(); 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鼠标按下并命中MarkerDragging记录高度开始拖拽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 ListNoteData notes new ListNoteData(); }这里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(ListNoteMarker 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.FromJsonNoteDataList(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模型经常没有默认ColliderPhysics.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清单甚至把备注数据流交给网络服务做多人协同标注这些方向都是在现有架构上自然生长出来的。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询