Unity当中的三维视觉范围判断以及遮挡处理
TODO:
- 敌人可视视椎的构建以及 editor 和 runtime 时的可视化 ✅
- 对控制角色在敌人三维视椎当中的判断✅
- 对遮挡物后面的角色进行视椎遮挡剔除处理 ✅
构建敌人可视范围的视椎
画出视椎体
首先我们创建两个比较规范的接口:
1 | // IFov.cs |
1 | // ISightAware |
接着我们去实现一个脚本,来存储这些数据,IsSight
是后面判断是否相交的。此处我们将视椎体的 Drawer 和其数据分开。
1 | // In FovDrawer.cs |
这里 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
9p = (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 | // In ChangeLineColor.cs |
可以看到实际效果还是很棒的。(此处不知道为什么我 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 | // In IBounds.cs |
1 | // In BoundsExposerBase |
首先是需要一个统一的接口 IBounds
,接着我们需要一个 mono 基类 BoundsExposerBase
,因为具体的实现肯定还是不一样的嘛。
接着我们就可以继承这个基类去实现各种特点的 Bounds Exposer 了。比如 cc:
1 | /// <summary> |
最终在我们 FindTarget 脚本里面就会是如下这样的:
1 | public class FindTarget : Conditional |
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
34private Vector3[] corners = new Vector3[4];
private RaycastHit[] raycastResults = new RaycastHit[5];
[ ]
[ ]
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 轴坐标数值应该相对来说大一点。但是这个相对关系,是根据敌人看向的向量来决定的,具体如何去决定,我觉得很繁琐(甚至想了一些投影相关的东西)。
参考: