StimuliSource和PerceptionListener

感知作为一种信号,整个场景中存在这个信号的生产者和消费者。这种信号在Unreal中被叫做刺激Stimuli
生产者就是StimuliSource,通过挂载StimuliSourceComponent并RegisterForSense来注册成为哪些类型刺激的刺激源
消费者就是PerceptionListener,通过挂载AIPerceptionComponent并配置SenseConfig来注册成为哪些类型刺激的监听者

Sense

每个Sense类代表一类感知,不同于StimuliSource和PerceptionListener,Sense并不实际存在于场景中,它是一种定义好的生产刺激的逻辑规则。UE中提供了几种基础常见的感知类型(视觉、听觉和队友等),游戏中涉及到的每种Sense都会唯一的初始化保存在PerceptionSystem中并对应一个SenseID
在这里插入图片描述
每种Sense都有自己的更新时间间隔,在PerceptionSystem的每个Tick中都会通过ProgressTime更新时间间隔,然后到时间后就Tick更新

class AIMODULE_API UAISense : public UObject
{
	bool ProgressTime(float DeltaSeconds)
	{
		TimeUntilNextUpdate -= DeltaSeconds;
		return TimeUntilNextUpdate <= 0.f;
	}

	void Tick()
	{
		if (TimeUntilNextUpdate <= 0.f)
		{
			TimeUntilNextUpdate = Update();
		}
	}
	virtual void RegisterSource(AActor& SourceActors){}
	virtual void UnregisterSource(AActor& SourceActors){}
	FORCEINLINE void OnNewListener(const FPerceptionListener& NewListener) { OnNewListenerDelegate.ExecuteIfBound(NewListener); }
	FORCEINLINE void OnListenerUpdate(const FPerceptionListener& UpdatedListener) { OnListenerUpdateDelegate.ExecuteIfBound(UpdatedListener); }
	FORCEINLINE void OnListenerRemoved(const FPerceptionListener& RemovedListener) { OnListenerRemovedDelegate.ExecuteIfBound(RemovedListener); }
}

每当有新的Actor注册作为这个Sense的触发源Source时会由PerceptionSystem找到对应的Sense调用RegisterSource。
每当有新的Actor注册监听这个Sense时会由PerceptionSystem调用OnNewListener。
每个Sense的工作基本分为三点

  1. 通过监听Listener的增删改事件维护所有该类型Sense的监听者
  2. 通过重写RegisterSource和UnRegisterSource维护所有改类型Sense的刺激源
  3. 在Tick(Update)中计算每个Listener能感知到哪些Source,为每个能感知到的Source创建Stimuli注册给Listener消费。

以常见的视觉感知AISense_Sight为例,每当有新的Source或者Listener都会创建一批新的Query,维护m*n个Query(假设有m个Source和n个Listener)

void UAISense_Sight::RegisterSource(AActor& SourceActor)
{
	RegisterTarget(SourceActor);
}

bool UAISense_Sight::RegisterTarget(AActor& TargetActor, const TFunction<void(FAISightQuery&)>& OnAddedFunc /*= nullptr*/)
{
	SCOPE_CYCLE_COUNTER(STAT_AI_Sense_Sight_RegisterTarget);
	
	FAISightTarget* SightTarget = ObservedTargets.Find(TargetActor.GetUniqueID());
	
	if (SightTarget != nullptr && SightTarget->GetTargetActor() != &TargetActor)
	{
		// this means given unique ID has already been recycled. 
		FAISightTarget NewSightTarget(&TargetActor);

		SightTarget = &(ObservedTargets.Add(NewSightTarget.TargetId, NewSightTarget));
		SightTarget->SightTargetInterface = Cast<IAISightTargetInterface>(&TargetActor);
	}
	else if (SightTarget == nullptr)
	{
		FAISightTarget NewSightTarget(&TargetActor);

		SightTarget = &(ObservedTargets.Add(NewSightTarget.TargetId, NewSightTarget));
		SightTarget->SightTargetInterface = Cast<IAISightTargetInterface>(&TargetActor);
	}

	for (AIPerception::FListenerMap::TConstIterator ItListener(ListenersMap); ItListener; ++ItListener)
	{
		if (RegisterNewQuery(Listener, ListenersTeamAgent, TargetActor, SightTarget->TargetId, TargetLocation, PropDigest, OnAddedFunc))
		{
			bNewQueriesAdded = true;
		}
	}
	// sort Sight Queries
	if (bNewQueriesAdded)
	{
		RequestImmediateUpdate();
	}

	return bNewQueriesAdded;
}
void UAISense_Sight::OnNewListenerImpl(const FPerceptionListener& NewListener)
{
	GenerateQueriesForListener(NewListener, PropertyDigest);
}
void UAISense_Sight::GenerateQueriesForListener(const FPerceptionListener& Listener, const FDigestedSightProperties& PropertyDigest, const TFunction<void(FAISightQuery&)>& OnAddedFunc/*= nullptr */)
{
	bool bNewQueriesAdded = false;
	const IGenericTeamAgentInterface* ListenersTeamAgent = Listener.GetTeamAgent();
	const AActor* Avatar = Listener.GetBodyActor();

	// create sight queries with all legal targets
	for (FTargetsContainer::TConstIterator ItTarget(ObservedTargets); ItTarget; ++ItTarget)
	{
		const AActor* TargetActor = ItTarget->Value.GetTargetActor();
		if (TargetActor == nullptr || TargetActor == Avatar)
		{
			continue;
		}

		const FVector TargetLocation = TargetActor->GetActorLocation();
		if (RegisterNewQuery(Listener, ListenersTeamAgent, *TargetActor, ItTarget->Key, TargetLocation, PropertyDigest, OnAddedFunc))
		{
			bNewQueriesAdded = true;
		}
	}

	// sort Sight Queries
	if (bNewQueriesAdded)
	{
		RequestImmediateUpdate();
	}
}

在Update中先按照距离等参数对其进行重要性排序,然后通过ComputeVisibilityUpdateQueryVisibilityStatus计算和更新可见性,对于可见的Query会调用RegisterStimuli产生刺激给Listener
Update中代码比较复杂就不贴了,可见性的检测基本就是射线检测加一些强制可见和强制不可见的条件逻辑,射线检测可以通过配置选择同步或者异步。先进行重要性排序的原因是在一次Update中有可能不会计算完所有的Query,计算数量受最大耗时MaxTimeSlicePerTick和最大数量MaxTracesPerTick的限制,两者都可以配置中修改

void UAISense_Sight::UpdateQueryVisibilityStatus(FAISightQuery& SightQuery, FPerceptionListener& Listener, const bool bIsVisible, const FVector& SeenLocation, const float StimulusStrength, AActor* TargetActor, const FVector& TargetLocation) const
{
	if (bIsVisible)
	{
		const bool bHasValidSeenLocation = SeenLocation != FAISystem::InvalidLocation;
		Listener.RegisterStimulus(TargetActor, FAIStimulus(*this, StimulusStrength, bHasValidSeenLocation ? SeenLocation : SightQuery.LastSeenLocation, Listener.CachedLocation));
		SightQuery.SetLastResult(true);
		if (bHasValidSeenLocation)
		{
			SightQuery.LastSeenLocation = SeenLocation;
		}
	}
	// communicate failure only if we've seen given actor before
	else if (SightQuery.GetLastResult())
	{
		Listener.RegisterStimulus(TargetActor, FAIStimulus(*this, 0.f, TargetLocation, Listener.CachedLocation, FAIStimulus::SensingFailed));
		SightQuery.SetLastResult(false);
		SightQuery.LastSeenLocation = FAISystem::InvalidLocation;
	}
}

Listener

Listener对应的是FPerceptionListener类型,但它基本只是存储标志位bHasStimulusToProcess和转发消息给真正的Listener也就是AIPerceptionComponent,这里的消息主要是指RegisterStimuli和ProcessStimuli
在AIPerceptionComponent中会维护一个生产消费队列StimuliToProcessRegisterStimuli向其中添加刺激,ProcessStimuli中全部消费掉。对已感知到的对象,会维护在PerceptualData中,在ProcessStimuli中也会对其Stimuli是否过期进行检测

struct AIMODULE_API FActorPerceptionInfo
{
	TWeakObjectPtr<AActor> Target;

	TArray<FAIStimulus> LastSensedStimuli;

	/** if != MAX indicates the sense that takes precedense over other senses when it comes
		to determining last stimulus location */
	FAISenseID DominantSense;
}
class AIMODULE_API UAIPerceptionComponent : public UActorComponent
{
	typedef TMap<TObjectKey<AActor>, FActorPerceptionInfo> TActorPerceptionContainer;
	typedef TActorPerceptionContainer FActorPerceptionContainer;
	FActorPerceptionContainer PerceptualData;
		
protected:	
	struct FStimulusToProcess
	{
		TObjectKey<AActor> Source;
		FAIStimulus Stimulus;

		FStimulusToProcess(AActor* InSource, const FAIStimulus& InStimulus)
			: Source(InSource), Stimulus(InStimulus)
		{

		}
	};

	TArray<FStimulusToProcess> StimuliToProcess; 
}
void UAIPerceptionComponent::RegisterStimulus(AActor* Source, const FAIStimulus& Stimulus)
{
	FStimulusToProcess& StimulusToProcess = StimuliToProcess.Add_GetRef(FStimulusToProcess(Source, Stimulus));
	StimulusToProcess.Stimulus.SetExpirationAge(MaxActiveAge[int32(Stimulus.Type)]);
}

Stimuli

刺激是一个消耗品,由PerceptionSystem通过每种Sense的逻辑基于Source和Listener的信息生产出来,提供给Listener消费,最终消费的结果其实就是我们最常用的OnPerceptionUpdate等事件的触发
刺激的属性主要包括Type(由哪种Sense产生)、Tag、Age(处理过期逻辑)和Strengh以及Location

USTRUCT(BlueprintType)
struct AIMODULE_API FAIStimulus
{
	GENERATED_USTRUCT_BODY()

	static const float NeverHappenedAge;

	enum FResult
	{
		SensingSucceeded,
		SensingFailed
	};

protected:
	UPROPERTY(BlueprintReadWrite, Category = "AI|Perception")
	float Age;

	UPROPERTY(BlueprintReadWrite, Category = "AI|Perception")
	float ExpirationAge;
public:
	UPROPERTY(BlueprintReadWrite, Category = "AI|Perception")
	float Strength;
	UPROPERTY(BlueprintReadWrite, Category = "AI|Perception")
	FVector StimulusLocation;
	UPROPERTY(BlueprintReadWrite, Category = "AI|Perception")
	FVector ReceiverLocation;
	UPROPERTY(BlueprintReadWrite, Category = "AI|Perception")
	FName Tag;

	FAISenseID Type;
}

PerceptionSystem

PerceptionSystem是整套感知系统运转的核心,首先在其中维护了所有的Source、Listener和Sense

class AIMODULE_API UAIPerceptionSystem : public UAISubsystem
{
	AIPerception::FListenerMap ListenerContainer;

	UPROPERTY()
	TArray<TObjectPtr<UAISense>> Senses;

	TMap<const AActor*, FPerceptionStimuliSource> RegisteredStimuliSources;
}

在Tick中的流程其实也非常直观可分为三步,全部是围绕Stimuli展开的

  1. 对已有的Stimuli更新Age,并标记过期的Stimuli为需要Listener处理
  2. 为每个Sense进行ProgressTime加Tick,这步可能产生许多新的Stimuli,标记这些Stimuli给Listener
  3. 对所有被前两步标记的Listener调用处理Stimuli
void UAIPerceptionSystem::Tick(float DeltaSeconds)
{
	bool bSomeListenersNeedUpdateDueToStimuliAging = false;
	if (NextStimuliAgingTick <= CurrentTime)
	{
		constexpr double Precision = 1./64.;
		const float AgingDt = FloatCastChecked<float>(CurrentTime - NextStimuliAgingTick, Precision);
		bSomeListenersNeedUpdateDueToStimuliAging = AgeStimuli(PerceptionAgingRate + AgingDt);
		NextStimuliAgingTick = CurrentTime + PerceptionAgingRate;
	}

	bool bNeedsUpdate = false;
	for (UAISense* const SenseInstance : Senses)
	{
		bNeedsUpdate |= SenseInstance != nullptr && SenseInstance->ProgressTime(DeltaSeconds);
	}

	if (bNeedsUpdate)
	{
		for (UAISense* const SenseInstance : Senses)
		{
			if (SenseInstance != nullptr)
			{
				SenseInstance->Tick();
			}
		}
	}
	{
		const bool bStimuliDelivered = DeliverDelayedStimuli(bNeedsUpdate ? RequiresSorting : NoNeedToSort);

		if (bNeedsUpdate || bStimuliDelivered || bSomeListenersNeedUpdateDueToStimuliAging)
		{
			for (AIPerception::FListenerMap::TIterator ListenerIt(ListenerContainer); ListenerIt; ++ListenerIt)
			{
				check(ListenerIt->Value.Listener.IsValid());

				if (ListenerIt->Value.HasAnyNewStimuli())
				{
					ListenerIt->Value.ProcessStimuli();
				}
			}
		}
	}
}
Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐