【日常随笔】UE虚幻引擎游戏大世界自动寻路测试技术

3 阅读10分钟

本文参考自本人的以下三篇CSDN文章,欢迎大家关注!

在基于UE引擎的大世界游戏当中,自动寻路是必须的一个功能,但实际研发过程中,可能因为策划漏配置寻路权重等情况,导致游玩体验有所影响。从游戏产品测试的角度,寻路测试的难点在于短时间无法对一批次的主干道进行测试,导致整体测试覆盖度不高,因此为了提升测试效率,需要通过游戏自动化测试技术来达到目的。

故本文以UE4为背景,介绍一种自动寻路测试技术的方式,供大家日常工作中参考。

认识recast&detour寻路技术

ue默认的寻路算法采用的是recast&detour,是最为经典实用的一个。要了解这个寻路技术的话,有非常多的文章,比如:

同时,官方也提供了demo项目工具可以游玩。demo工具部署方面,首先克隆recastnavigation项目,从文档中可以看到RecastDemo的构建支持Windows、Linux、MacOS三端。从实际测试的效果来看,MacOS可能存在字体无法加载的问题,建议是用Windows跟Linux跑着玩。以Windows为例,首先需要下载premake5以及SDL开发库VC。premake5需要放到PATH下,而SDL开发库VC解压后需要按照文档描述,放到recastnavigation项目目录的RecastDemo/Contrib目录下,更名为SDL

之后,在RecastDemo目录下执行premake5 vs2019,可以在RecastDemo/Build/vs2019中看到recastnavigation.sln项目文件。用VS2019打开,构建RecastDemo,就会生成exe在RecastDemo/Bin目录下。进入这个目录执行RecastDemo.exe,就能打开工具界面了。

基本操作方面,打开软件,在右侧Properties选中SampleSolo MeshInput Meshnav_test.obj,下拉点击Build,就能看到生成navmesh的结果。在左侧的Tools栏下,点击Test NavMesh,然后在地表上用右键(shift+左键)以及左键分别标定起始位置和结束位置,就能够直接看到寻路路径生成的结果。左侧的Tools栏里面,点选Pathfind Straight,可以看到寻路路径的点位连接,点选Pathfind Sliced,可以看到寻路查找的整个过程。

Solo Mesh场景下,很容易出现寻路平面的断层,比如在楼梯处没有生成寻路网格,导致不能自动上楼。解决这个问题的方法,第一种是在Properties中,增加AgentMax Climb,使得楼梯高度能够符合navmesh生成的标准。第二种是调整SampleTile Mesh,使得navmesh能够以一个地块tile为单位生成,地块与相邻地块之间也会计算连通性,从而使得寻路网格变成一个整体。在这个基础上,navmesh就会覆盖到每个台阶。之后,再使用跳点off-mesh连接每个台阶,使得台阶之间能够成为通路。如果是用Tile Mesh,可以直接调整SampleTile Mesh,调小Cell SizeTile Size,点击Build生成,再点选NavMesh Portals,就可以看到现在navmesh覆盖的范围以及不同tile之间的交界。在台阶之间加上off-mesh,可以在左边Tools选择Create Off-Mesh Links去加双向的link。加完之后测试寻路,就能看到上楼的路径了。

此外,还有一种常见的需要调整的参数,是地块的寻路成本,通常与地形的类型有关,比如在水里面,寻路的成本就高,在大路上,寻路的成本就低,并且有些时候,策划就希望玩家自动寻路能够优先去找有大路的路径。这个时候,可以通过Convex Volumes包裹/标识一些路面,在寻路的时候,这些路面相对于一般的路面,会有不同的成本计算规则。在左侧的Create Convex Volumes选项中,我们可以创建一些包围盒,将特定的路面包裹住,标识这些路面的“地形”。比如下图的例子,我们在中间的小路标识了“Water”水体,最终寻路的结果,会从陆地绕道过去。

在demo的NavMeshTesterTool.cpp中,我们可以看到不同“地形”对应的寻路成本定义。实际游戏开发时,寻路成本也有自定义的必要。

void NavMeshTesterTool::init(Sample* sample)
{
    m_sample = sample;
    m_navMesh = sample->getNavMesh();
    m_navQuery = sample->getNavMeshQuery();
    recalc();

    if (m_navQuery)
    {
        // Change costs.
        m_filter.setAreaCost(SAMPLE_POLYAREA_GROUND, 1.0f);
        m_filter.setAreaCost(SAMPLE_POLYAREA_WATER, 10.0f);
        m_filter.setAreaCost(SAMPLE_POLYAREA_ROAD, 1.0f);
        m_filter.setAreaCost(SAMPLE_POLYAREA_DOOR, 1.0f);
        m_filter.setAreaCost(SAMPLE_POLYAREA_GRASS, 2.0f);
        m_filter.setAreaCost(SAMPLE_POLYAREA_JUMP, 1.5f);
    }
    
    m_neighbourhoodRadius = sample->getAgentRadius() * 20.0f;
    m_randomRadius = sample->getAgentRadius() * 30.0f;
}

寻路自动化测试技术设计

了解了寻路的原理,就可以开始做自动测试寻路的技术设计。UE构造的3D大世界中,可能包含不同的地形,比如山地、水体、公路等,如果忽略这些地形信息,那么计算出来的寻路路径,效果就可能趋于一条直线。如果玩家真按照这样的路径行走的话,很可能会出现走到水里或者障碍物的情况,导致实际会没有走到一个最符合现实的路径方案。为了解决这个问题,在UE4编辑器中,也支持通过不同类型的NavModifierVolume标记网格路面,从而使得每个路面在实际寻路计算中,具备不同的经过成本cost

有兴趣的读者,可以查看官方文档的资料,详细了解:

假设游戏是采用后台寻路的方案,整个自动化测试的流程可以这样做:

首先,我们需要预先准备NavModifierVolume配置数据,通过这些数据,才能判断玩家行走在怎样的路面上。同时,也要准备一系列测试用例,包含寻路的起点终点、期望时间和路面占比等等,整个自动化的流程需要遍历这些内容。

然后,需要一套自动化驱动寻路的流程,并且需要在寻路过程中,以特定的频率收集玩家所经过的路径点。这样,每一次寻路我们都能够得到玩家的路径点集,从而能够反映玩家的寻路效果。

对于每个路径点,需要求得这些点对应的是怎样的路面,这时候就需要用到预先准备的NavModifierVolume配置数据了。这个计算过程实质上是求在3D空间内,一个点是否在一个长方体内。由于每个NavModifierVolume导出的transform数据中,包含位置、旋转以及长宽高数据,进行计算判断。

最终,整个自动化测试过程完成后,我们就可以得到这样一些数据:

  • 结果汇总:每个起点终点的寻路测试结果以及所有路径点跟路径点所对应的路面
  • 是否通过指标:寻路是否成功、寻路时长是否符合预期、寻路路径占比是否符合预期

通过表格或者3D散点图对结果进行可视化处理,就可以得到一份更加清晰的寻路测试报告。通过报告,我们可能可以排查出这样的问题:

  • 寻路路面配置缺失或不对,或因为寻路路面配置变化导致寻路效果相较以前有较大差别
  • 因副本切换等原因重新生成阻挡物件,或者navmesh生成效果与实际物件摆放有出入,导致寻路实际阻塞

计算一个点是否在一个长方体内

在上文当中,预留了一个数学问题是,求在3D空间内,一个点是否在一个长方体(Box)内。通常而言,这个问题可能是以下解法:

但因为UE4环境有自己的坐标系定义,因此本环节介绍一下这个问题在UE引擎范畴内的解法。针对UE4环境,一个长方体会包含transform以及单位大小的信息:

  • 位置Location:中心点
  • 旋转Rotation:Pitch、Yaw、Roll
  • 大小比例Scale

因此这个问题可以用这样的步骤解决(应该是对的吧,数学不好= =):

  • 获取中心点到目标点A的向量,其中目标点A是我们需要判断是否在长方体内的点
  • 对这个向量进行基于Rotation的逆运算,这样目标点的位置会变化,得到新的目标点B
  • 判断目标点B,是否在一个以Location为中心点,Scale*单位长度大小的AABB中

这里面最需要解决的,是如何求旋转。我们可以通过UE内部的源码来寻找思路。

// UnrealMath.cpp

FVector FRotator::UnrotateVector(const FVector& V) const
{
    return FRotationMatrix(*this).GetTransposed().TransformVector( V );
}

FVector FRotator::RotateVector(const FVector& V) const
{
    return FRotationMatrix(*this).TransformVector( V );
}

由一个旋转FRotator(带Pitch、Yaw、Roll属性),以及一个向量,可以直接求得旋转后/逆旋转后的向量。旋转一个向量需要构建特定的旋转矩阵,通过矩阵乘法得到新向量分量的值。逆旋转的矩阵则是旋转矩阵的转置,而转换原向量的计算方式也是相同的。

针对不同的坐标系规则,旋转矩阵的计算方法有很多种,而实测UE4用的旋转矩阵也是独特的一种(试过网上的一些旋转矩阵老是有分量正负号不对= =)。在FRotationMatrixFRotationTranslationMatrix的定义中,我们可以看到旋转矩阵的构造方法:

// RotationTranslationMatrix.h

// Origin should be (0.0, 0.0, 0.0) if not defined
FORCEINLINE FRotationTranslationMatrix::FRotationTranslationMatrix(const FRotator& Rot, const FVector& Origin)
{
    float SP, SY, SR;
    float CP, CY, CR;
    FMath::SinCos(&SP, &CP, FMath::DegreesToRadians(Rot.Pitch));
    FMath::SinCos(&SY, &CY, FMath::DegreesToRadians(Rot.Yaw));
    FMath::SinCos(&SR, &CR, FMath::DegreesToRadians(Rot.Roll));

    M[0][0] = CP * CY;
    M[0][1] = CP * SY;
    M[0][2] = SP;
    M[0][3] = 0.f;

    M[1][0] = SR * SP * CY - CR * SY;
    M[1][1] = SR * SP * SY + CR * CY;
    M[1][2] = - SR * CP;
    M[1][3] = 0.f;

    M[2][0] = -( CR * SP * CY + SR * SY );
    M[2][1] = CY * SR - CR * SP * SY;
    M[2][2] = CR * CP;
    M[2][3] = 0.f;

    M[3][0] = Origin.X;
    M[3][1] = Origin.Y;
    M[3][2] = Origin.Z;
    M[3][3] = 1.f;
}

可以看到旋转矩阵是一个4x4的结构(因为可能有设定原点坐标)。而之后,TransformVector是这样计算的:

// Matrix.inl

// Transform vector
/** 
 * Transform a direction vector - will not take into account translation part of the FMatrix. 
 * If you want to transform a surface normal (or plane) and correctly account for non-uniform scaling you should use TransformByUsingAdjointT.
 */
FORCEINLINE FVector4 FMatrix::TransformVector(const FVector& V) const
{
    return TransformFVector4(FVector4(V.X,V.Y,V.Z,0.0f));
}

// Homogeneous transform.

FORCEINLINE FVector4 FMatrix::TransformFVector4(const FVector4 &P) const
{
    FVector4 Result;
    VectorRegister VecP = VectorLoadAligned(&P);
    VectorRegister VecR = VectorTransformVector(VecP, this);
    VectorStoreAligned(VecR, &Result);
    return Result;
}

// UnrealMathSSE.h

/**
 * Calculate Homogeneous transform.
 *
 * @param VecP VectorRegister 
 * @param MatrixM FMatrix pointer to the Matrix to apply transform
 * @return VectorRegister = VecP*MatrixM
 */
FORCEINLINE VectorRegister VectorTransformVector(const VectorRegister&  VecP,  const void* MatrixM )
{
    const VectorRegister *M = (const VectorRegister *) MatrixM;
    VectorRegister VTempX, VTempY, VTempZ, VTempW;

    // Splat x,y,z and w
    VTempX = VectorReplicate(VecP, 0);
    VTempY = VectorReplicate(VecP, 1);
    VTempZ = VectorReplicate(VecP, 2);
    VTempW = VectorReplicate(VecP, 3);
    // Mul by the matrix
    VTempX = VectorMultiply(VTempX, M[0]);
    VTempY = VectorMultiply(VTempY, M[1]);
    VTempZ = VectorMultiply(VTempZ, M[2]);
    VTempW = VectorMultiply(VTempW, M[3]);
    // Add them all together
    VTempX = VectorAdd(VTempX, VTempY);
    VTempZ = VectorAdd(VTempZ, VTempW);
    VTempX = VectorAdd(VTempX, VTempZ);

    return VTempX;
}

最终其实就是原向量[[x, y, z, 0]](1x4)乘以转置矩阵(4x4),得到新的1x4的向量,也就是我们需要的旋转后的向量。UE4内部对计算过程做了优化,此处暂不多做分析。

得到了计算向量旋转的方法,我们也可以通过其转置矩阵,进行向量的某个旋转的逆运算,这样目标点相对于中心点的位置有所改变,但对应的长方体就是对齐坐标轴的了。这时就可以直接通过判断点的三个分量是不是在长方体X、Y、Z范围内,就能得出答案。