TODO:
- 敌人可视视椎的构建以及 editor 和 runtime 时的可视化 ✅
- 对控制角色在敌人三维视椎当中的判断✅
- 对遮挡物后面的角色进行视椎遮挡剔除处理 ✅
构建敌人可视范围的视椎
画出视椎体
首先我们创建两个比较规范的接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public interface IFov { float FieldOfViewAngle { get; } float Aspect { get; } float FarDistance { get; } float HorizontalAngle => FieldOfViewAngle * Aspect; float HalfVerticalAngle => FieldOfViewAngle / 2.0f; float HalfHorizontalAngle => HorizontalAngle / 2.0f; }
|
1 2 3 4 5 6 7 8 9 10
|
public interface ISightAware { bool IsSight(Vector3 target); }
|
接着我们去实现一个脚本,来存储这些数据,IsSight
是后面判断是否相交的。此处我们将视椎体的 Drawer 和其数据分开。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
|
[SerializeField] [LabelText("FOV 组件")] private FovSight fovSight;
private void OnDrawGizmos() { Gizmos.color = Color.green; float dist = fovSight.FarDistance / Mathf.Cos(fovSight.HalfVerticalAngle * Mathf.Deg2Rad) / Mathf.Cos(fovSight.HalfHorizontalAngle * Mathf.Deg2Rad); Vector3 left = -transform.right; Vector3 up = transform.up; Vector3 originDir = transform.forward; Quaternion topQua = Quaternion.AngleAxis(fovSight.HalfVerticalAngle, left); Quaternion bottomQua = Quaternion.AngleAxis(-fovSight.HalfVerticalAngle, left); Quaternion rightQua = Quaternion.AngleAxis(fovSight.HalfHorizontalAngle, up); Quaternion leftQua = Quaternion.AngleAxis(-fovSight.HalfHorizontalAngle, up);
Vector3 topLeftDir = topQua * leftQua * originDir; Vector3 topRightDir = topQua * rightQua * originDir; Vector3 bottomLeftDir = bottomQua * leftQua * originDir; Vector3 bottomRightDir = bottomQua * rightQua * originDir; Vector3 originPos = transform.position; Vector3 topLeftPos = originPos + topLeftDir * dist; Vector3 topRightPos = originPos + topRightDir * dist; Vector3 bottomLeftPos = originPos + bottomLeftDir * dist; Vector3 bottomRightPos = originPos + bottomRightDir * dist; Gizmos.DrawLine(originPos, topLeftPos); Gizmos.DrawLine(originPos, topRightPos); Gizmos.DrawLine(originPos, bottomLeftPos); Gizmos.DrawLine(originPos, bottomRightPos); Gizmos.DrawLine(topLeftPos, topRightPos); Gizmos.DrawLine(topRightPos, bottomRightPos); Gizmos.DrawLine(bottomRightPos, bottomLeftPos); Gizmos.DrawLine(bottomLeftPos, topLeftPos); }
|
这里 FovSight 暴露出来是为了方便其在 editor 时期就得到 fov 组件。这时候看看效果:
runtime 时期的视椎体如果有需要可以使用 LineRenderer
绘制,此处逻辑和 Gizmos 当中的是一样的。
对控制角色在敌人三维视椎当中的判断
视椎体与 AABB 相交判断
此处使用最简单的 AABB 包围盒来介绍,通常,视椎体与包围盒会有三种关系:
- 包围盒完全在视椎体内部
- 包围盒完全在视椎体外部
- 包围盒部分在视锥体内部(相交)
换算到我们敌人 AI 上面,那么只有两种情况:视椎可见 和 视椎完全不可见。但是也可以对其应用不同的粒度,比如包围盒完全在视椎体内才算感知到对方,当然这些都是后话。
前情提要这里为了方便,我对整个视椎只定义出了三个变量:
因此就导致其会只有五个面。我们也只需要判断这五个面。
如何判断某个点是否在视椎体内
要想知道某个点是否在视椎体内,那就需要知道某个点是否在某个平面内,此处在平面内指的是:点在平面法线的同一侧。
这个判断的办法也很简单,也就是使用法线和平面上一点表示出平面方程,代入对应点计算结果大于零,那么其就在平面的内部。
Ax+By+Cz+D=0
其中,平面法线就为(当然此处肯定得做归一化):
normal=(A,B,C)
接着在实际代码中,看了下 unity 的 Plane
,我觉得满足不了我的需求,因此我自己重新写了一个 FovPlane
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
|
public struct FovPlane { private Vector3 normal; private float coefficientD;
public Vector3 Normal => normal;
public FovPlane(Vector3 a, Vector3 b, Vector3 c) { Vector3 ab = b - a; Vector3 ac = c - a; normal = Vector3.Cross(ab, ac).normalized; coefficientD = -normal.x * a.x - normal.y * a.y - normal.z * a.z; }
public bool OnNormalSide(Vector3 point) { return normal.x * point.x + normal.y * point.y + normal.z * point.z + coefficientD > 0.0f; } }
|
此处容易注意的坑就是:unity 世界坐标是左手系,因此需要注意构建平面时三个点的顺序排布。
如何判断 AABB 包围盒是否在视锥体内部
我们很容易会想到,在 AABB 包围盒当中,八个点的坐标都在视椎体所以面之外不就可以了吗,但是请看下图:
图中的黄色方块,所有点都在视椎外部,但是其与视椎却是相交的。
那我们又想,所有点都在视椎同一个平面的外部,那包围盒肯定在视椎外部了吧。这次没错,但是反过来是错的。
图中橙色的方块,虽然所有点没有在同一个面的外部,但是它却在视锥体的外部。
如果是真正做渲染里面的视椎裁剪,那么会使用第二种办法,因为要在结果正确的前提下,做快速判断,那么肯定不能用第一种,第二种虽然会把多余数据传给 GPU,但是至少保证了正确性。
那还有没有更快的办法?肯定有。
我们根本不需要判断八个点。我们只需要找到两个点,一个是 AABB 离平面最近的顶点 p
,一个是 AABB 离平面最远的顶点 n
。其中:
- 如果 p 在平面外侧可以说明这个 AABB 都在平面外侧
- 如果 n 在平面内侧可以说明这个 AABB 都在平面内侧
- 如果 p 在内侧,n 在外侧,那么说明相交
如何去找到这两个点?很简单,以下是一个(伪)代码示例,其中 normal 为平面法线:
1 2 3 4 5 6 7 8 9
| p = (xmin,ymin,zmin) if (normal.x >= 0) p.x = xmax; if (normal.y >=0) p.y = ymax; if (normal.z >= 0) p.z = zmax:
n = (xmax,ymax,zmax) if (normal.x >= 0) n.x = xmin; if (normal.y >=0) n.y = ymin; if (normal.z >= 0) n.z = zmin:
|
在我实际的项目当中就是这样实现的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
public bool IsSight(Bounds bounds) { var (topLeftPos, topRightPos, bottomLeftPos, bottomRightPos) = AllPoints; Vector3 originPos = transform.position; FovPlane[] fovPlanes = { new FovPlane(topLeftPos, topRightPos, bottomLeftPos), new FovPlane(originPos, topRightPos, topLeftPos), new FovPlane(originPos, topLeftPos, bottomLeftPos), new FovPlane(originPos, bottomLeftPos, bottomRightPos), new FovPlane(originPos, bottomRightPos, topRightPos) }; foreach (FovPlane fovPlane in fovPlanes) { Vector3 p = new Vector3( fovPlane.Normal.x >= 0 ? bounds.max.x : bounds.min.x, fovPlane.Normal.y >= 0 ? bounds.max.y : bounds.min.y, fovPlane.Normal.z >= 0 ? bounds.max.z : bounds.min.z ); if (!fovPlane.OnNormalSide(p)) return false; } return true; }
|
因为此处对于敌人 AI 的判断,只需要把相交和完全包裹包围盒都算成可以看到玩家,因此此优化到了只判断一个点即可。
接着我们写两个行为树的脚本来测试一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
public class FindPlayer : Conditional { public SharedGameObject player; private ISightAware sightAware; private CharacterController cc; public override void OnAwake() { sightAware = GetComponent<FovSight>() as ISightAware; cc = player.Value.GetComponent<CharacterController>(); } public override TaskStatus OnUpdate() { if (sightAware.IsSight(cc.bounds)) { return TaskStatus.Success; } return TaskStatus.Failure; } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
public class ChangeLineColor : Action { public Color color; private LineRenderer lineRenderer; public override void OnAwake() { lineRenderer = GetComponent<LineRenderer>(); } public override TaskStatus OnUpdate() { lineRenderer.startColor = color; lineRenderer.endColor = color; return TaskStatus.Success; } }
|
可以看到实际效果还是很棒的。(此处不知道为什么我 line renderer 莫名其妙又消失了,明天再研究吧)
一些踩坑记录
为什么我的 LineRenderer 修改颜色无效
对于缺失 shader 基础的我,这个问题确实困扰了我不少时间,在自己 google 以及多方查阅之下。本来我为 LineRenderer 使用的 material 是 Unity 内置的 Default-Line
。但是其使用的 unity shader 并没有 _Color
这个属性,因此这也就是我们无法更改颜色的原因。
解决办法:自己创建一个 material,unity shader 使用内置的 Unlit/Color
,这样就解决啦,要切换颜色只需要:
1 2 3 4
| private LineRenderer lineRenderer;
lineRenderer.sharedMaterial.SetColor("_Color", color);
|
行为树结构的修改
直接上图(和上面的图片所示架构还是有区别):
如何设计一个比较通用的 FindTarget 节点
这里是我本人的一些拙见。
首先前情提要,FindTarget 节点主要是去获取其物体对应的 Bounds,那么这个通用指的就是,无需去关心这个 Bounds 到底是从什么组件上面获取的,Mesh ? Collider ? 抑或是 CharacterController, 都不需要关心,只需要知道这个物体能获取到一个 Bounds 就行了。
因此我想到了这样一个办法:通过挂载的 Mono 脚本将其这个 Bounds 暴露出来。下面是具体实现:
1 2 3 4 5 6
|
public interface IBounds { Bounds GetBounds(); }
|
1 2 3 4 5 6
|
public abstract class BoundsExposerBase : MonoBehaviour, IBounds { public abstract Bounds GetBounds(); }
|
首先是需要一个统一的接口 IBounds
,接着我们需要一个 mono 基类 BoundsExposerBase
,因为具体的实现肯定还是不一样的嘛。
接着我们就可以继承这个基类去实现各种特点的 Bounds Exposer 了。比如 cc:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
[RequireComponent(typeof(CharacterController))] public class CcBoundsExposer : BoundsExposerBase { private CharacterController cc;
private void Awake() { cc = GetComponent<CharacterController>(); }
public override Bounds GetBounds() { return cc.bounds; } }
|
最终在我们 FindTarget 脚本里面就会是如下这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public class FindTarget : Conditional { public SharedGameObject target;
private ISightAware sightAware; private IBounds boundsInterface;
public override void OnAwake() { sightAware = GetComponent<FovSight>() as ISightAware; boundsInterface = target.Value.GetComponent<BoundsExposerBase>() as IBounds; }
public override TaskStatus OnUpdate() { if (sightAware.IsSight(boundsInterface.GetBounds())) { return TaskStatus.Success; } return TaskStatus.Failure; } }
|
FindTarget 只需要关心放进来的 target 有没有挂载一个 Bounds Exposer 就行了。
对完全被遮挡物遮挡的角色进行视椎遮挡剔除处理
试想一个场景:玩家在一个完全能遮挡其的墙体后面,即使在敌人的视觉视椎当中,是不是也不应该被感知到呢。这就是最后要处理的问题。
其实很简单,我们只需要在玩家的 aabb 上面找四个点,从敌人的位置打四条射线过去,然后判断中间是否会与特定 layermask 的物体相交。如果四个点都相交,则证明被完全遮挡。
直接上代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| private Vector3[] corners = new Vector3[4]; private RaycastHit[] raycastResults = new RaycastHit[5];
[LabelText("不可感知物体")] [InfoBox("注:此为不可感知物体的 LayerMask,会遮挡可感知物体,为了避免后续可能更改 LayerMask 的顺序,此处没有硬编码")] public LayerMask imperceptibleLayerMask;
private bool IsObstructedByImperceptibleBounds(Bounds bounds) { corners[0] = bounds.min; corners[1] = new Vector3(bounds.min.x, bounds.min.y, bounds.max.z); corners[2] = new Vector3(bounds.min.x, bounds.max.y, bounds.min.z); corners[3] = bounds.max;
foreach (Vector3 corner in corners) { Vector3 direction = (corner - transform.position).normalized; float distance = Vector3.Distance(transform.position, corner);
int hitCount = Physics.RaycastNonAlloc(transform.position, direction, raycastResults, distance, imperceptibleLayerMask);
if (hitCount == 0) { return false; } }
return true; }
|
其实此处 aabb 的四个点我也是让 GPT 找的,无伤大雅,精确度不会差太多。
其实这个部分被我想难了很多,比如我想过把两个 aabb 拿来对比其 xyz 的坐标远近。但是这个方案会有两个问题:
- 如何去存储所有标明了不可被感知的 bounds,存储了之后,如何让各个模块之间访问变量也是问题。因为这个方案最开始我还做了一个 Model 层,然后使用 qf 的 Controller 来获取数据。这是很繁琐而且会占用内存的,如果场景中遮挡物多了之后。
- 到底如何去评定这个 xyz 轴坐标的远近关系,比如如何我的敌人看向 z轴,那么对于被遮挡物来说,我的 xy 轴坐标数值应该是相对来说应该小一点,那么 z 轴坐标数值应该相对来说大一点。但是这个相对关系,是根据敌人看向的向量来决定的,具体如何去决定,我觉得很繁琐(甚至想了一些投影相关的东西)。
参考: