首页 / 资讯中心 / 文章详情

Unity UI指向箭头:屏幕空间方向指示的数学原理与完整实现

Unity UI指向箭头:屏幕空间方向指示的数学原理与完整实现 ★ FEATURED ARTICLE
1. 为什么UI指向箭头是个值得深挖的小功能做Unity项目的人多半遇到过这种需求界面上有个目标物体可能在屏幕外可能在某个UI面板后面也可能只是需要引导玩家视线这时候就需要一个箭头从屏幕边缘或者从某个固定位置指向目标。听起来简单但真做起来坑比想象中多得多。我第一次接触这个需求是在一个模拟类项目里场景中有一堆可交互的设备玩家需要快速定位到某个特定设备。最初的想法很朴素——在目标头顶挂个世界空间的箭头模型不就行了结果一测试就发现问题目标在相机背面时箭头直接消失目标在屏幕边缘时箭头被裁掉一半多个目标同时存在时箭头互相重叠UI面板弹出时箭头被挡在后面。这些问题逼着我从“挂个模型”转向“用UI系统做屏幕空间的指向箭头”。UI指向箭头这个功能核心解决的是屏幕空间中的方向指示问题。它要能做到几件事目标在屏幕内时箭头指向目标位置目标在屏幕外时箭头贴在屏幕边缘指向目标方向目标被遮挡或者不可见时箭头能给出合理的视觉反馈。适合所有做Unity UI交互、引导系统、任务指示的开发者参考不管你是刚接触Unity的新手还是做了几年但没系统整理过这块的老手都能从中找到可以直接抄作业的方案。这篇文章我会从整体设计思路讲起把数学原理拆开揉碎然后给出完整的实操步骤和代码最后把我踩过的坑和排查经验整理成速查表。全程用UGUI体系不依赖任何第三方插件保证你跟着做就能跑起来。2. 整体设计思路与方案选型2.1 为什么不用世界空间箭头最直觉的方案是在目标物体上挂一个3D箭头模型让它始终朝向相机。这个方案在目标始终可见且位于屏幕中央区域时没问题但一旦目标偏离屏幕中心问题就来了。世界空间的箭头会被透视投影影响距离相机越远看起来越小距离越近看起来越大。如果目标在相机背后箭头会被近裁剪面裁掉或者直接翻转。如果目标在屏幕边缘箭头模型可能只有一半在视口内。更麻烦的是当UI面板弹出时世界空间的箭头会被UI挡住因为UI默认渲染在场景之上。还有一个容易被忽略的问题世界空间箭头的旋转是三维的当目标在屏幕边缘时箭头指向屏幕外的方向在三维空间中可能是一个很别扭的角度看起来不自然。而UI箭头始终在屏幕平面上旋转视觉上更符合“指示方向”的直觉。所以结论很明确指向箭头应该做在UI层用屏幕空间坐标计算。这样箭头永远面向相机不会被透视影响不会被3D物体遮挡而且可以通过Canvas的Sort Order控制它和其他UI的层级关系。2.2 屏幕空间与视口空间的坐标系选择Unity里做屏幕空间计算有两个坐标系可选屏幕坐标Screen Space和视口坐标Viewport Space。屏幕坐标的原点在屏幕左下角范围是(0,0)到(Screen.width, Screen.height)。视口坐标的原点也在左下角但范围是(0,0)到(1,1)。两者之间的转换很简单除以屏幕宽高就行。我选择用视口坐标做核心计算原因是视口坐标天然归一化不受分辨率影响。判断目标是否在屏幕内只需要检查视口坐标的x和y是否在0到1之间。计算屏幕边缘的贴边位置时视口坐标的边界就是0和1非常直观。最后再把视口坐标转回屏幕坐标去设置UI元素的位置。这个选择在后续做分辨率适配时优势更明显。不管屏幕是1920x1080还是2560x1440视口坐标的判断逻辑完全不用改只需要在最后设置UI位置时用实际的屏幕尺寸做转换。2.3 核心数学原理拆解整个功能的核心数学就三件事判断目标在不在屏幕内、计算目标在屏幕上的位置、计算目标在屏幕外时的边缘位置和角度。判断目标是否在屏幕内把目标的世界坐标通过相机的WorldToViewportPoint转成视口坐标得到的结果是一个Vector3x和y是视口坐标z是目标到相机的深度。如果z小于0说明目标在相机背后直接判定为屏幕外。如果z大于0再看x和y是否在0到1之间都在就是屏幕内否则就是屏幕外。这里有个细节WorldToViewportPoint返回的x和y在目标位于屏幕外时可能超出0到1的范围比如x等于1.2或者y等于-0.3。这些值在后续计算边缘位置时有用不要急着截断。计算屏幕内目标的位置视口坐标乘以屏幕宽高得到屏幕坐标再把屏幕坐标转换到Canvas的本地坐标。如果Canvas是Screen Space - Overlay模式屏幕坐标直接就是Canvas坐标假设Canvas的缩放为1。如果是Screen Space - Camera模式需要用RectTransformUtility.ScreenPointToLocalPointInRectangle做转换。计算屏幕外目标的边缘位置这是最核心的部分。目标在屏幕外时从屏幕中心到目标视口坐标的连线会与屏幕边界相交交点就是箭头应该贴的位置。具体做法是把视口坐标平移到以屏幕中心为原点的坐标系即减去(0.5, 0.5)得到方向向量。然后计算这个方向向量与四条边界x0.5, x-0.5, y0.5, y-0.5的交点取距离最小的那个交点。最后把交点平移回(0.5, 0.5)的坐标系再转成屏幕坐标。角度计算就简单了用Mathf.Atan2(direction.y, direction.x)得到弧度再转成角度赋值给箭头的Z轴旋转。2.4 方案对比与选型结论方案优点缺点适用场景世界空间3D箭头实现简单直接挂模型透视影响大小会被遮挡屏幕外消失目标始终可见且居中的简单场景UI屏幕空间箭头大小固定不被遮挡屏幕外可贴边需要数学计算多目标需管理引导系统、任务指示、复杂UI场景屏幕边缘指示器视觉统一适合多目标实现复杂度最高需要处理重叠大型场景多目标导航选型结论很明确用UI屏幕空间箭头。它平衡了实现复杂度和视觉效果适合绝大多数项目。如果目标数量多可以在它基础上加一个边缘指示器管理器统一处理多个箭头的布局和重叠。3. 核心细节解析与实操要点3.1 Canvas与箭头的层级设置Canvas的Render Mode选Screen Space - Overlay还是Screen Space - Camera直接影响后续的坐标转换逻辑。Screen Space - Overlay模式下Canvas直接覆盖在屏幕上不受任何相机影响。这种模式下屏幕坐标和Canvas坐标的转换最简单RectTransformUtility.ScreenPointToLocalPointInRectangle可以直接用。缺点是箭头永远在最上层如果希望箭头被某些UI面板遮挡就做不到。Screen Space - Camera模式下Canvas绑定到一个相机可以设置Plane Distance控制它和相机的距离。这种模式下箭头可以被其他UI遮挡层级关系更灵活。但坐标转换需要多一步要把屏幕坐标转到Canvas的本地坐标。我一般推荐用Screen Space - Overlay因为指向箭头的核心诉求是“始终可见”被遮挡反而不符合预期。如果确实需要遮挡效果可以在箭头下面加一个半透明的背景板用颜色区分层级。箭头的层级顺序通过Transform的Sibling Index控制。在Hierarchy里越靠下的UI元素渲染越靠上。把箭头放在所有UI元素的最后面它就会渲染在最上层。如果项目用了多个Canvas通过Canvas的Sort Order控制数值越大越靠上。3.2 箭头UI元素的制作箭头可以用Image加一个箭头形状的Sprite也可以用Text加一个箭头字符。我推荐用Image加Sprite因为可以精确控制箭头的旋转中心和大小。制作步骤很简单在Canvas下新建一个Image命名为Arrow。把箭头图片拖到Source Image字段。如果没有现成的箭头图片可以用Unity自带的Knob或者自己画一个。关键是把Image的RectTransform的Pivot设置为(0.5, 0.5)这样旋转时围绕中心点转不会偏移。箭头的大小建议设置为64x64或者48x48太小了看不清太大了会挡住其他UI。如果箭头需要指向目标箭头的默认朝向应该是向上即旋转角度为0时箭头指向上方。如果图片默认朝右需要在代码里加一个-90度的偏移。注意箭头的Pivot一定要设为中心否则旋转时箭头会绕着边角转位置会飘。这个坑我踩过调了半天才发现是Pivot的问题。3.3 目标物体的引用管理每个箭头需要知道它指向哪个目标。最简单的方式是在箭头脚本上暴露一个Transform字段在Inspector里拖入目标物体。但这种方式在目标动态生成时不好用因为没法提前拖引用。更灵活的方式是用一个管理器脚本维护一个目标列表动态创建和管理箭头。管理器在Update里遍历所有目标计算每个目标的位置更新对应箭头的位置和旋转。目标被销毁时对应的箭头也销毁。如果目标数量不多少于10个直接在箭头脚本上拖引用完全够用。如果目标数量多或者动态变化用管理器模式更合适。管理器模式还有一个好处可以在一个地方统一处理箭头的重叠问题比如当两个箭头靠得太近时自动错开它们的位置。3.4 屏幕内与屏幕外的判断逻辑判断逻辑的代码大概长这样Vector3 viewportPos camera.WorldToViewportPoint(target.position); bool isBehind viewportPos.z 0; bool isInside !isBehind viewportPos.x 0 viewportPos.x 1 viewportPos.y 0 viewportPos.y 1;这里有个容易忽略的点当目标在相机背后时WorldToViewportPoint返回的x和y是镜像的。比如目标在相机左后方返回的x可能是正的。所以必须先判断z是否小于0如果小于0直接判定为屏幕外不要用x和y做判断。还有一个边界情况目标刚好在相机位置时WorldToViewportPoint的结果可能不稳定。实际项目中目标不会和相机重合但如果你做的是第一人称视角相机移动时可能瞬间穿过目标这时候箭头会闪一下。解决办法是加一个最小距离判断当目标到相机的距离小于某个阈值时隐藏箭头。3.5 边缘位置的精确计算屏幕外目标的边缘位置计算是整个功能里最需要仔细处理的部分。我把它拆成几步第一步把视口坐标平移到以屏幕中心为原点。viewportPos.x减去0.5viewportPos.y减去0.5得到相对坐标relPos。第二步计算relPos与四条边界的交点。边界是x0.5、x-0.5、y0.5、y-0.5。对于每条边界计算从原点沿relPos方向到达该边界的参数t。以x0.5为例t 0.5 / relPos.x。同理算出四个t值取其中最小的正数t。第三步用t乘以relPos得到交点坐标再加上(0.5, 0.5)平移回视口坐标系。第四步把视口坐标转成屏幕坐标再转成Canvas本地坐标。这里有个细节当relPos.x为0时除以0会得到无穷大需要特殊处理。实际代码里可以用Mathf.Approximately判断或者直接用Mathf.Min配合Mathf.Infinity处理。角度计算用relPos而不是交点坐标因为relPos的方向就是目标相对于屏幕中心的方向箭头应该指向这个方向。角度用Mathf.Atan2(relPos.y, relPos.x) * Mathf.Rad2Deg得到然后减去90度如果箭头默认朝上。3.6 边界留白与箭头偏移箭头贴在屏幕边缘时如果正好贴在边界上可能会被屏幕裁掉一半。所以需要给箭头加一个边距让它稍微往屏幕内缩一点。边距的大小取决于箭头的尺寸。如果箭头是64x64边距至少要是32这样箭头的一半在屏幕内一半在屏幕外也不会被裁。我一般设置边距为箭头宽度的一半再加10个像素的余量。边距的处理方式有两种一种是在计算交点时把边界从0.5缩到0.5减去边距比例另一种是在得到屏幕坐标后手动偏移。第一种方式更优雅因为它在视口坐标系里统一处理不受分辨率影响。边距比例等于边距像素除以屏幕宽高。提示边距不要设得太大否则箭头会离屏幕边缘太远看起来像是飘在屏幕中间。一般32到48像素就够了。4. 完整实操过程与核心代码实现4.1 场景搭建与UI结构先搭一个测试场景。新建一个3D场景放一个Cube作为目标物体再放一个Plane作为地面方便观察目标的位置。相机用默认的Main Camera位置在(0, 1, -10)朝向(0, 0, 0)。然后建UI。在Hierarchy里右键UI - Canvas新建一个Canvas。Canvas的Render Mode保持Screen Space - OverlayCanvas Scaler的UI Scale Mode设为Scale With Screen SizeReference Resolution设为1920x1080Match设为0.5。这样在不同分辨率下UI元素的大小和位置都能自适应。在Canvas下新建一个Image命名为Arrow。给它设置一个箭头Sprite。如果没有现成的可以用Unity的默认Sprite或者自己画一个。把RectTransform的Width和Height都设为64Pivot设为(0.5, 0.5)。再新建一个Text或者Image作为调试信息显示目标是否在屏幕内、视口坐标等信息。这个在调试阶段很有用正式发布时可以删掉。4.2 核心脚本编写新建一个C#脚本命名为UIPointerArrow挂到Arrow上。脚本的核心逻辑如下using UnityEngine; using UnityEngine.UI; public class UIPointerArrow : MonoBehaviour { public Transform target; public Camera targetCamera; public float edgePadding 40f; public float rotationOffset -90f; private RectTransform rectTransform; private RectTransform canvasRect; private Image arrowImage; void Start() { rectTransform GetComponentRectTransform(); canvasRect GetComponentInParentCanvas().GetComponentRectTransform(); arrowImage GetComponentImage(); if (targetCamera null) targetCamera Camera.main; } void Update() { if (target null || targetCamera null) { arrowImage.enabled false; return; } arrowImage.enabled true; Vector3 viewportPos targetCamera.WorldToViewportPoint(target.position); bool isBehind viewportPos.z 0; if (isBehind) { // 目标在相机背后翻转方向 viewportPos.x 1f - viewportPos.x; viewportPos.y 1f - viewportPos.y; } bool isInside !isBehind viewportPos.x 0 viewportPos.x 1 viewportPos.y 0 viewportPos.y 1; Vector2 screenPos; float angle; if (isInside) { screenPos new Vector2(viewportPos.x * Screen.width, viewportPos.y * Screen.height); angle 0f; // 屏幕内时箭头可以隐藏或者缩小 rectTransform.localScale Vector3.one * 0.8f; } else { Vector2 relPos new Vector2(viewportPos.x - 0.5f, viewportPos.y - 0.5f); float paddingX edgePadding / Screen.width; float paddingY edgePadding / Screen.height; float tX relPos.x 0 ? (0.5f - paddingX) / relPos.x : relPos.x 0 ? (-0.5f paddingX) / relPos.x : Mathf.Infinity; float tY relPos.y 0 ? (0.5f - paddingY) / relPos.y : relPos.y 0 ? (-0.5f paddingY) / relPos.y : Mathf.Infinity; float t Mathf.Min(tX, tY); Vector2 edgePos relPos * t new Vector2(0.5f, 0.5f); screenPos new Vector2(edgePos.x * Screen.width, edgePos.y * Screen.height); angle Mathf.Atan2(relPos.y, relPos.x) * Mathf.Rad2Deg rotationOffset; rectTransform.localScale Vector3.one; } Vector2 localPos; RectTransformUtility.ScreenPointToLocalPointInRectangle( canvasRect, screenPos, null, out localPos); rectTransform.anchoredPosition localPos; rectTransform.localRotation Quaternion.Euler(0, 0, angle); } }这段代码的核心逻辑和前面拆解的一致。几个关键点再强调一下isBehind的判断必须在isInside之前因为目标在背后时viewportPos的x和y是镜像的不能直接用来判断是否在屏幕内。edgePadding除以屏幕宽高得到视口坐标下的边距比例这样边距在不同分辨率下保持一致。rotationOffset默认-90度是因为箭头图片默认朝右如果箭头默认朝上这个值设为0。4.3 参数调试与效果验证把脚本挂到Arrow上把Cube拖到target字段。运行场景移动Cube观察箭头的表现。Cube在屏幕中央时箭头应该也在屏幕中央角度为0。Cube往右移出屏幕箭头应该贴在屏幕右边缘指向右方。Cube往左上角移出屏幕箭头应该贴在左上角边缘指向左上方向。Cube移到相机背后箭头应该贴在屏幕边缘指向目标在屏幕上的投影方向。如果箭头的位置或角度不对先检查rotationOffset是否正确。如果箭头贴边时被裁掉一半增大edgePadding。如果箭头在屏幕内时位置偏移检查Canvas的Render Mode和RectTransform的Pivot。调试阶段可以把viewportPos、isInside、screenPos这些值打到屏幕上方便定位问题。我一般会在Update里用Debug.Log输出关键值但注意不要每帧都输出否则Console会刷屏。可以加一个计数器每30帧输出一次。4.4 多目标管理与箭头池单个箭头跑通后扩展到多目标。新建一个UIPointerArrowManager脚本挂到Canvas上。管理器维护一个目标列表和箭头列表在Update里遍历所有目标更新对应箭头。using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class UIPointerArrowManager : MonoBehaviour { public Camera targetCamera; public GameObject arrowPrefab; public ListTransform targets new ListTransform(); private ListUIPointerArrow arrows new ListUIPointerArrow(); void Start() { if (targetCamera null) targetCamera Camera.main; foreach (var target in targets) { GameObject arrowObj Instantiate(arrowPrefab, transform); UIPointerArrow arrow arrowObj.GetComponentUIPointerArrow(); arrow.target target; arrow.targetCamera targetCamera; arrows.Add(arrow); } } void Update() { for (int i 0; i targets.Count; i) { if (targets[i] null) { arrows[i].gameObject.SetActive(false); } else { arrows[i].gameObject.SetActive(true); } } } }这个管理器很简单但已经能处理目标动态销毁的情况。如果目标数量会动态增减可以在Update里检查targets列表的变化动态创建或销毁箭头。更高效的方式是用对象池预先创建一批箭头需要时激活不需要时隐藏避免频繁的Instantiate和Destroy。多箭头重叠的问题可以在管理器里加一个简单的错开逻辑计算所有箭头的位置如果两个箭头距离小于阈值就把它们沿垂直于连线的方向错开。这个逻辑稍微复杂但效果很明显尤其是在目标集中在屏幕同一侧时。5. 常见问题与排查技巧实录5.1 箭头位置偏移或抖动最常见的问题是箭头位置和预期不符。排查顺序如下先确认Canvas的Render Mode如果是Screen Space - CameraScreenPointToLocalPointInRectangle的相机参数要传Canvas的worldCamera不能传null。再确认RectTransform的PivotPivot不是(0.5, 0.5)时anchoredPosition的设置会偏移。最后确认Canvas Scaler的设置Scale With Screen Size模式下Canvas的本地坐标和屏幕坐标不是1:1的关系需要用RectTransformUtility做转换。抖动问题通常是因为在Update里直接用了target.position而目标物体在FixedUpdate里移动。解决办法是把箭头的更新放到LateUpdate里确保在目标移动之后再更新箭头位置。5.2 目标在相机背后时箭头方向错误前面提到过目标在相机背后时WorldToViewportPoint返回的x和y是镜像的。如果不做处理箭头会指向错误的方向。解决办法是在判断isBehind之后把viewportPos.x和y做1减操作翻转回正确的方向。这个操作在代码里已经包含了但很多人会忘记。还有一种情况目标刚好在相机平面上z等于0WorldToViewportPoint的结果可能不稳定。实际项目中很少遇到但如果遇到了可以加一个判断当z的绝对值小于0.01时直接隐藏箭头。5.3 边缘箭头被屏幕裁切箭头贴在屏幕边缘时被裁掉原因是箭头的位置正好在屏幕边界上箭头的一半超出了屏幕。解决办法是加edgePadding让箭头往屏幕内缩。edgePadding的大小至少是箭头尺寸的一半。如果箭头是64x64edgePadding至少32。我一般设40到48留一点余量。如果加了edgePadding还是被裁检查Canvas的RectTransform是否和屏幕一样大。如果Canvas的尺寸小于屏幕箭头在Canvas边缘时就会超出屏幕。确保Canvas的RectTransform的Width和Height等于Reference Resolution并且Canvas Scaler的Match设置正确。5.4 多箭头重叠的错开策略多个目标在屏幕同一侧时箭头会挤在一起。简单的错开策略是按角度排序相邻箭头之间保持最小角度间隔。比如有5个箭头都在右侧角度范围是-30度到30度就把它们均匀分布在这个范围内。更简单的策略是检测箭头之间的距离如果小于阈值就把它们沿垂直于屏幕中心连线的方向错开。这个策略实现简单效果也够用。具体做法是在管理器的LateUpdate里两两比较箭头位置如果距离小于阈值就把它们分别向相反方向移动几个像素。提示错开逻辑不要放在每个箭头的Update里否则箭头之间会互相影响产生抖动。放在管理器的LateUpdate里统一处理所有箭头的位置都确定之后再错开。5.5 性能优化与更新频率单个箭头的Update开销很小但如果有几十个箭头每帧都做WorldToViewportPoint和坐标转换累积起来也不少。优化方式有几种降低更新频率比如每2帧更新一次用Job System或者Burst做批量计算只在目标移动时更新目标静止时不更新。我一般用降低更新频率的方式每2到3帧更新一次视觉上几乎看不出差别但CPU开销减半。如果目标数量超过50个可以考虑用对象池加批量计算把所有目标的坐标转换放在一个循环里减少函数调用开销。5.6 常见问题速查表问题现象可能原因排查方法解决方案箭头位置偏移Canvas Render Mode不对检查ScreenPointToLocalPointInRectangle的相机参数Screen Space - Camera时传worldCamera箭头旋转中心不对RectTransform Pivot不是中心检查Pivot值设为(0.5, 0.5)目标背后箭头方向反了未处理镜像坐标检查isBehind分支viewportPos.x和y做1减边缘箭头被裁未加边距检查edgePadding增大到箭头尺寸的一半以上箭头抖动更新时机不对检查是否在Update里改到LateUpdate多箭头重叠未做错开检查管理器加错开逻辑分辨率变化后位置错未做分辨率适配检查Canvas Scaler用Scale With Screen Size5.7 独家避坑经验第一个坑WorldToViewportPoint的z值。很多人只判断x和y是否在0到1之间忽略了z。当目标在相机背后时x和y可能在0到1之间但z是负的。如果不判断z箭头会显示在错误的位置。这个坑我在第一个项目里踩过调了一下午才发现。第二个坑Canvas的Scale。如果Canvas Scaler的UI Scale Mode设为Constant Pixel SizeCanvas的本地坐标和屏幕坐标是1:1的ScreenPointToLocalPointInRectangle的结果直接就是anchoredPosition。但如果设为Scale With Screen SizeCanvas的本地坐标会被缩放必须用RectTransformUtility做转换不能手动算。我见过有人直接拿屏幕坐标赋值给anchoredPosition在1920x1080下没问题换到2560x1440就偏了。第三个坑箭头的旋转。如果箭头图片默认朝右rotationOffset要设-90度。如果默认朝上设0度。如果默认朝左下设45度。这个偏移量取决于图片的默认朝向没有统一标准只能根据实际图片调。建议在代码里把rotationOffset暴露出来方便在Inspector里调。第四个坑多相机的场景。如果场景里有多个相机WorldToViewportPoint要用正确的相机。比如UI相机和主相机分开时目标物体应该用主相机做转换而不是UI相机。这个错误很隐蔽因为两个相机的视角可能差不多箭头看起来位置对但稍微移动就偏了。第五个坑目标被销毁时的空引用。如果目标在运行时被销毁target字段会变成nullUpdate里访问target.position会报空引用异常。解决办法是在Update开头判断target是否为null如果是就隐藏箭头并返回。这个判断很简单但很多人会忘记。6. 进阶扩展与实战建议6.1 箭头动画与视觉反馈基础功能跑通后可以加一些动画让箭头更生动。比如目标在屏幕外时箭头呼吸式缩放提示玩家注意。目标进入屏幕内时箭头淡出避免遮挡。目标被点击时箭头闪烁一下给出反馈。呼吸式缩放用Mathf.PingPong或者Mathf.Sin实现在Update里根据Time.time计算缩放值。淡出用CanvasGroup的alpha配合Mathf.Lerp做插值。闪烁用Color.Lerp在白色和原色之间切换。这些动画不要做得太夸张否则会分散玩家注意力。呼吸式缩放的幅度控制在0.1左右淡出的时间控制在0.3秒左右闪烁的频率控制在每秒2到3次。6.2 距离显示与目标筛选如果目标距离玩家很远可以在箭头旁边显示距离数值。距离用Vector3.Distance计算取整后显示。距离的更新频率可以低一些每10帧更新一次减少字符串拼接的开销。目标筛选是指只显示一定范围内的目标箭头超出范围的目标不显示箭头或者显示为灰色。这个逻辑在管理器里实现遍历目标时判断距离超过阈值就隐藏对应箭头。阈值可以根据游戏类型调整探索类游戏可以设大一些竞技类游戏可以设小一些。6.3 不同相机模式下的适配透视相机和正交相机的WorldToViewportPoint行为略有不同。透视相机下目标在相机背后时z为负x和y镜像。正交相机下目标在相机背后时z也为负但x和y不镜像因为正交投影没有透视效果。所以isBehind的处理逻辑在正交相机下要调整不能简单做1减。判断相机是透视还是正交用camera.orthographic属性。如果是正交相机isBehind时直接把箭头放在屏幕边缘的对应位置不做镜像翻转。这个细节在切换相机模式时容易忽略导致箭头方向错误。6.4 移动端的触摸适配移动端没有鼠标箭头的交互方式不同。如果箭头需要点击要用Touch输入。Unity的Input.GetTouch或者EventSystem的IPointerClickHandler都可以处理。移动端的屏幕比例和PC不同边距和箭头尺寸需要根据屏幕DPI调整。移动端还有一个问题屏幕小箭头容易挡住其他UI。解决办法是把箭头做小一些或者把箭头放在屏幕边缘的更外侧。我一般把移动端的箭头尺寸设为PC的一半边距设为PC的1.5倍。6.5 实战中的取舍建议最后分享几个实战中的取舍经验。如果项目时间紧直接用单个箭头脚本加拖引用不要做管理器够用就行。如果目标数量超过5个再做管理器。如果目标数量超过20个再考虑对象池和批量计算。箭头的视觉样式不要过度设计简单的三角形或者圆形加箭头就够了。玩家关注的是方向不是箭头的花纹。颜色用醒目的红色或者黄色和UI主色调区分开。测试时一定要在多种分辨率下跑一遍1920x1080、2560x1440、1280x720都试试。分辨率问题在开发阶段不容易发现上线后玩家反馈才暴露就晚了。我在实际项目中的体会是UI指向箭头这个功能核心难点不在代码而在边界情况的处理。屏幕内、屏幕外、相机背后、多目标重叠、分辨率变化每一种情况都要考虑到。代码写完之后花在调试和测试上的时间往往是写代码的两三倍。但一旦调通它就是一个非常稳定的模块后续项目可以直接复用改改参数就能用。
阅读完成 · 觉得有帮助?
咨询建站