DEV, AI

HTN实现浅析

Hierarchical Task Network(HTN)分层任务网络是一种常见的游戏AI规划方案。类似于行为树,HTN提供了在不同条件分支下执行不同任务的能力,但是相比于行为树Runtime响应式的执行,HTN更拥有着一种全局规划的能力。

当前版本比较潦草,仅仅为了记录,后续会陆续修改

HTN类似于行为树有一个树形的执行流,所执行的单位也是类似于行为树的Task(这也说明了行为树和HTN的结构非常类似)。但是在执行树的任务之前,会先做一个全局规划,以深度优先搜索的策略遍历整颗树,将所有匹配的Task(这里的Task存在先后顺序,Task也不一定是叶子节点)按顺序存在TaskQueue中,在实际执行阶段,再依次从队列中执行Task。 这里还有一个非常重要的特性,除了上文说的Task不是叶子节点,可以显式的在树结构中构建Task的执行顺序以外。Task本质上还是一个HierarchicalTask,简单说就是Task可以是一个SubHTN,SubHTN中又包含了一系列Task,所有我们这里可以约定最终执行Task为PrimitiveTask。显然这种分层设计提供了一种分而治之的设计能力。不过因为行为树也支持SubTree,所以个人认为HTN优点还是在于规划能力,以及在规划的策略下对Ai行为的设计,更趋向于宏观角度。

HTN简单来说分为两个阶段

  1. Planning 构建计划
  2. Running 执行阶段

后续的部分会着重分析上述两个阶段,所有的代码均以类C++的伪码形式,修改自UE行为树插件

1. HTN实现阶段分析

1.Planning


Planning阶段就是一次行为的规划,目的遍历HTNTree获取所有PrimitiveTask。因为HTNTree根据之前的简述是一颗分层树的结构,所以我们以深度优先搜索的方式从根节点来遍历整棵树。一种典型的优先搜索的实现方式是用栈来存放待处理的节点,每次出栈一个节点处理,并将产生的SubNodes/ChildrenNodes压入栈,依次执行到栈为空,文字伪码描述的执行过程如下:

增加根复合任务到分解列表中
对于每个在我们分解列表中的任务(for)
  移除任务
  如果任务是复合任务
    找到满足当前条件状态并且能处理该复合任务的方法
    如果该方法找到了,增加方法的任务到分解列表中
    如果没找到,恢复到之前分解任务的状态中
  如果任务是原始任务
    在当前状态下执行任务
    增加任务到最终计划列表

这个过程非常简单,但是依然有一些不同于普通深度遍历的点需要考虑。 接下来将分析其中一些细节的实现,以UEHTN插件的实现作为例子 UAITask_MakeHTNPlan派生于UE::AITask,是一个生命周期和AI同级的对象,专门负责Planning阶段的对象

Members:

  • PlanHeap 如上文所说的计划栈
  • CurrentPlanStepID 当前计划极端
  • CurrentPlanToExpand 当前需要去展开处理的Plan

在AITask::Active时(即当AI激活时)执行Planning

UAITask_MakeHTNPlan::DoPlanning()
    while(true)
    {
        // 类似深度优先搜索
        CurrentPlanToExpand = DequeueCurrentBestPlan()
        if (CurrentPlanToExpand)
        {
            MakeExpansionsOfCurrentPlan(CurrentPlanToExpand)
        }
    }
UAITask_MakeHTNPlan::DequeueCurrentBestPlan
    return PlanHeap.HeapPop()

得到PlanToExpand后,执行MakeExpansions

UAITask_MakeHTNPlan::MakeExpansionsOfCurrentPlan()
    // 获取当前Plan下的WorldState和Nodes,这里会从Plan中的当前TaskNode获取子节点
    TSharedPtr<FBlackboardWorldState> WorldState;
	TArrayView<UHTNStandaloneNode*> NextNodes;
	CurrentPlanToExpand->GetWorldStateAndNextNodes(CurrentPlanStepID, WorldState, NextNodes);
    foreach(var node in NextNodes)
    {
        MakeExpansionsOfCurrentPlan(WorldState, Node)
        {
            // Initialize
            Node->Init()

            // Check condition
            // 主要检查WorldState,注意WorldState会在Planning阶段随着PlanSubmit而改变
            if (!EnterDecorators())
                continue;

            // Make plane
            Node->MakePlanExpansions()
        }
    }

每个HTNTaskNode自己执行规划,传入PlanningContext

+ HTNTask::MakePlanExpansions(PlanningContext)
+ HTNTask::CreatePlanSteps() 
    Task规划阶段主要是对WorldState备份状态的修改,以及添加到PlanningTaskList
    - NewWorldState = WorldState->MakeNext();  
    - NewWorldState->SetValueKey, Value);  
    - UAITask_MakeHTNPlan.SubmitPlanStep(this, NewWorldState, cost);  

UAITask_MakeHTNPlan收到Node调用SubmitPlanStep后,会接受Plan,放入待执行队列, 同时也将接受WorldState的改变

UAITask_MakeHTNPlan::SubmitPlanStep(Task, NewWorldState, cost)
    // 复制创建了一个新的Plan并添加了新的Task
    var AddedPlanStep = nullptr
    var NewPlan = PlanningContext.MakePlanCopyWithAddedStep(AddedStep, AddedStepID);
    NewPlan->Cost += AddedStep->Cost;

    // Submit plan!!
    CurrentPlanningContext.SubmitCandidatePlan(NewPlan, Description);
        PlanHeap.push(NewPlan)

    添加到PlanHeap后,会在下一次DoPlanning中去执行

2. Running


Running阶段基本上和行为树类似,只是按顺序执行RunningPlan中的PlanStep,注意之前步骤的MakePlanning只是为了收集需要执行的Task队列,为了规划所做的WorldState创建都是基于期望的备份修改,实际执行过程中, 依然需要按步骤一次执行,所有的EnterConditiong依然需要去检测

2.1 基础的数据结构

enum class EHTNTaskStatus : uint8
{
	Active,
	Aborting,
	Inactive
};

enum class EHTNNodeResult : uint8
{
	// Finished as success
	Succeeded,
	// Finished as failure
	Failed,
	// Finished aborting = failure
	Aborted,
	// Not finished yet
	InProgress
};

接下来定义下Plan中Level的定义(个人认为更类似于深度) 普通Task节点的顺序执行时Level不变,也就是说Level只会收到一些特殊的Composite节点而产生影响 ,例如Branch在产生分支,每个分支都会是新的不同的Level。 或者通俗的说只是以Level划分不同的执行队列,只是为了基于不同Composite节点实现不同的执行流控制

struct HTNLevel
{
    TArray<FHTNPlanStep> Steps;
}

2.2 获取下一个原子任务

FHTNGetNextStepsContext 先看一下如何从当前Plan中获取下一个原子任务,并添加到PendingExecutionStepIDs

UHTNPlanInstance::GetNextPrimitiveStepsInCurrentPlan(&PendingExecutionStepIDs)
{
    auto& OutStepIDs = PendingExecutionStepIDs;
	FHTNGetNextStepsContext Context(*this, *CurrentPlan, bIsExecutingPlan, OutStepIDs);
	Context.AddNextPrimitiveStepsAfter(StepID);
	return Context.GetNumSubmittedSteps();    
}

FHTNGetNextStepsContext::SubmitPlanStep()
{
    OutStepIds.Add(PlanStepID);
	++curSubmitted;
}

FHTNGetNextStepsContext::AddNextPrimitiveStepsAfter(StepID)
{
    // 获取当前Level下,下一个有效的TaskStep
    // 
    // 即遍历当前Level下所有Steps,递归GetNextPrimitiveSteps
    // 一旦有新的StepSubmitted,则直接返回
    FHTNPlanLevel& Level = *Levels[CurrentStepID.LevelIndex];
    for (int32 StepIndex = InStepID.StepIndex + 1; StepIndex < Level.Steps.Num(); ++StepIndex)
	{
		const FHTNPlanStep& CandidateStep = Level.Steps[StepIndex];
		CandidateStep.Node->GetNextPrimitiveSteps(*this, {InStepID.LevelIndex, StepIndex});
        if (const int32 NumSubmittedNow = GetNumStepsSubmittedNow())
		{
			return NumSubmittedNow;
		}
	}
}

2.2 Tick执行过程

UHTNPlanInstance

UHTNPlanInstance::TickCurrentPlan(float DeltaTime)
    StartTasksPendingExecution()


UHTNPlanInstance::StartTasksPendingExecution
    var stepID = PendingExecutionStepIDs.pop()
    StartExecuteTask(StartExecuteTask)

UHTNPlanInstance::StartExecuteTask(PlanStepID)

    const FHTNPlanStep& PlanStep = CurrentPlan->GetStep(PlanStepID);
	UHTNTask& Task = *CastChecked<UHTNTask>(PlanStep.Node);

    // 添加到ExecutingQueue
	check(!CurrentlyExecutingStepIDs.Contains(PlanStepID));
	CurrentlyExecutingStepIDs.Add(PlanStepID);

    // Subnodes Begin

    // Subnodes end

    // Execute the current task
	uint8* const TaskMemory = GetNodeMemory(PlanStep.NodeMemoryOffset);
	const EHTNNodeResult Result = Task.ExecuteTask(TaskMemory);
	if (Result != EHTNNodeResult::InProgress && CurrentlyExecutingStepIDs.Contains(PlanStepID))
		OnTaskFinished(&Task, TaskMemory, PlanStepID, Result);

	return Result;

每个HTNTaskNode执行任务

HTNTask::ExecuteTask(RuntimeMemory) -> return EHTNodeResult
    // 首先为了节省内存,类似于行为树的做法,采用静态的树节点结构和每个实例的RuntimeNode
    // RuntimeNode只包含了少量的Runtime数据
    HTNTaskRuntime* const RuntimeNode = CastInstanceNodeMemory<HTNTaskRuntime>(RuntimeMemory);   
    var result = EHTNNodeResult::Succeeded;

    // Do something...

    // Return result
    return result;

例如一个HTNTask_Move任务的Execute如下:

HTNTask_Move::ExecuteTask(RuntimeNode)


3. 部分细节分析

1. Parallel实现分析

</br>


参考引用:

[1] https://www.jianshu.com/p/196962c7ae6a 一篇文章搞懂hierarchical task network(HTN)-通过实例探讨分层任务网络规划