TODO:

  • 敌人可视视椎的构建以及 editor 和 runtime 时的可视化 ✅
  • 对控制角色在敌人三维视椎当中的判断✅
  • 对遮挡物后面的角色进行视椎遮挡剔除处理 ✅

构建敌人可视范围的视椎

画出视椎体

首先我们创建两个比较规范的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// IFov.cs
public interface IFov
{
/// <summary>
/// 垂直可视角度
/// </summary>
float FieldOfViewAngle { get; }
/// <summary>
/// 宽高比
/// </summary>
float Aspect { get; }

/// <summary>
/// 远平面距离
/// </summary>
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
// ISightAware

/// <summary>
/// 具有意识到周围的视觉范围的能力
/// </summary>
public interface ISightAware
{
// 后期这里应该是要改为 Bounds
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
// In FovDrawer.cs

[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 上面,那么只有两种情况:视椎可见视椎完全不可见。但是也可以对其应用不同的粒度,比如包围盒完全在视椎体内才算感知到对方,当然这些都是后话。

前情提要这里为了方便,我对整个视椎只定义出了三个变量:

  • 垂直可视高度
  • 宽高比
  • 远平面距离

因此就导致其会只有五个面。我们也只需要判断这五个面。

如何判断某个点是否在视椎体内

要想知道某个点是否在视椎体内,那就需要知道某个点是否在某个平面内,此处在平面内指的是:点在平面法线的同一侧

这个判断的办法也很简单,也就是使用法线和平面上一点表示出平面方程,代入对应点计算结果大于零,那么其就在平面的内部。

其中,平面法线就为(当然此处肯定得做归一化):

接着在实际代码中,看了下 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
/// <summary>
/// 专为敌人 AI 的 FOV 视椎提供的一个平面类
/// </summary>
public struct FovPlane
{
private Vector3 normal;

/// <summary>
/// 平面方程的 D 系数,其余系数分别就是对应 [normal] x y z
/// </summary>
private float coefficientD;

public Vector3 Normal => normal;

/// <summary>
/// 使用三个点构成一个平面,使用 ab cross ac 构建,注意 unity 为左手坐标系
/// </summary>
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;
}

/// <summary>
/// 判断一个点是否在平面的法线侧
/// </summary>
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
// In FovSight.cs

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)
{
// the closet point to plane
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
// In FindPlayer.cs

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
// In ChangeLineColor.cs

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
// cache LineRenderer
private LineRenderer lineRenderer;

lineRenderer.sharedMaterial.SetColor("_Color", color);

行为树结构的修改

直接上图(和上面的图片所示架构还是有区别):

如何设计一个比较通用的 FindTarget 节点

这里是我本人的一些拙见。

首先前情提要,FindTarget 节点主要是去获取其物体对应的 Bounds,那么这个通用指的就是,无需去关心这个 Bounds 到底是从什么组件上面获取的,Mesh ? Collider ? 抑或是 CharacterController, 都不需要关心,只需要知道这个物体能获取到一个 Bounds 就行了。

因此我想到了这样一个办法:通过挂载的 Mono 脚本将其这个 Bounds 暴露出来。下面是具体实现:

1
2
3
4
5
6
// In IBounds.cs

public interface IBounds
{
Bounds GetBounds();
}
1
2
3
4
5
6
// In BoundsExposerBase

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
/// <summary>
/// BoundsExposer for Character Controller
/// </summary>
[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;

/// <summary>
/// 是否被其它不可感知的 bounds 完全遮挡
/// </summary>
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);

// No ImperceptibleBounds
if (hitCount == 0)
{
return false;
}
}

return true;
}

其实此处 aabb 的四个点我也是让 GPT 找的,无伤大雅,精确度不会差太多。

其实这个部分被我想难了很多,比如我想过把两个 aabb 拿来对比其 xyz 的坐标远近。但是这个方案会有两个问题:

  • 如何去存储所有标明了不可被感知的 bounds,存储了之后,如何让各个模块之间访问变量也是问题。因为这个方案最开始我还做了一个 Model 层,然后使用 qf 的 Controller 来获取数据。这是很繁琐而且会占用内存的,如果场景中遮挡物多了之后。
  • 到底如何去评定这个 xyz 轴坐标的远近关系,比如如何我的敌人看向 z轴,那么对于被遮挡物来说,我的 xy 轴坐标数值应该是相对来说应该小一点,那么 z 轴坐标数值应该相对来说大一点。但是这个相对关系,是根据敌人看向的向量来决定的,具体如何去决定,我觉得很繁琐(甚至想了一些投影相关的东西)。

参考: