UE AI里的感知实现流程解析
感知与被感知流程
刺激源的配置与注册
配置结构
UPROPERTY(EditAnywhere, Category = "AI Perception", BlueprintReadOnly)
TArray<TSubclassOf<UAISense> > RegisterAsSourceForSenses;
向刺激源组件UAIPerceptionStimuliSourceComponent
中添加配置
注册刺激配置到感知系统
- 重写父类里的
OnRegister
函数,过滤掉RegisterAsSourceForSenses
里无效的配置,根据bAutoRegisterAsSource
的成员变量控制是否自注册到感知系统 bAutoRegisterAsSource
如果为true则执行RegisterWithPerceptionSystem
函数,该函数首先拿到感知系统实例指针,然后迭代配置结构中的UAISense
调用感知系统中的RegisterSourceForSenseClass
来完成刺激源Sense的注册及刺激源组件持有者的注册
感知组件的配置、注册、与信息更新
感知配置项
UPROPERTY(EditDefaultsOnly, Instanced, Category = "AI Perception")
TArray<TObjectPtr<UAISenseConfig>> SensesConfig;
这是感知类型配置,与刺激源配置不同的是,感知配置对UAISense进行了封装,里面添加了感知间隔的有效时间(Age),调试颜色控制,开启允许与否的控制等
注册感知配置到感知系统
- 同样的配方,在重写的
OnRegister
函数里进行处理,首先在持有者AIController的OnEndPlay
委托上绑定OnOwnerEndPlay
函数,该函数用于在AIController玩法结束时取消所有的感知系统里的感知注册 - 循环
SensesConfig
调用RegisterSenseConfig函数,该函数首先对SenseConfig进行脱壳,获取UAISense
,然后调用感知系统的RegisterSenseClass
函数注册UAISense配置,这个配置与刺激源注册配置是一致的 - 添加感知组件的
PerceptionFilter
白名单 - 设置刺激源留存的最大有效间隔时间(Age),这个留存时间保留在
TArray<float> MaxActiveAge
里,这个数组的索引与SenseID保持一致 - 最后一步是调用感知系统的
UpdateListener
函数来完成在感知系统中的感知注册,该函数做了以下处理:
- 检查当前传入的Listener是否有效,如果有效,则说明传入的是已经注册过的Listener,那么调用
FPerceptionListener
的UpdateListenerProperties
函数来更新该Listener的teamid及感知白名单,再调用感知系统的OnListenerUpdate
函数,而该函数是迭代了已经注册的Senses配置实例,转调了Sense的OnListenerUpdate
,而内部封装的是委托执行(接后续) - 如果无效,则说明这是一个新的感知组件,则分配一个有效的感知ID,并向
ListenerContainer
添加一个新的感知实例成员,接着调用感知组件的OnNewListener
函数,该函数与OnListenerUpdate
函数类似,迭代注册的Senses
,调用Sense的OnNewListener
函数(接后续)
感知系统中对刺激源的处理
对刺激源的注册请求处理
- 接收到
UAIPerceptionStimuliSourceComponent
刺激源组件的RegisterSourceForSenseClass
函数调用后,获取该刺激源的FAISenseID
并检查该ID是否被实例化过,没有被实例化则调用RegisterSenseClass
,接着调用RegisterSource
注册该刺激Sense的持有者 RegisterSenseClass
函数首先做的事情就是通过CDO调用UpdateSenseID
更新FAISenseID
分配一个有效的ID给当前Sense配置,接下来关键的一步是生成一个UAISense实例添加到感知系统的Senses结构里。然后根据UAISense
里的bWantsNewPawnNotification
和bAutoRegisterAllPawnsAsSources
控制参数,来决定是否把世界场景下的所有Pawn都注册为该配置的刺激源Actor,如果是,则在World下一帧开启一个计时器执行刺激源Actor的添加,具体流程如下:
- 调用
RegisterAllPawnsAsSourcesForSense
函数,根据传入的SenseID迭代World下的APawn
,然后调用RegisterSource
函数 RegisterSource
函数根据迭代把每一个APawn添加为SourcesToRegister
的数据成员
- 调用
RegisterSource
函数,把当前持有该UAISense
的作为刺激源添加到SourcesToRegister
里,其实这里可以用if..else..
来区分开的,不过问题不大,因为向SourcesToRegister
里添加数据的时候用的是AddUnqiue
函数
对感知组件的SenseConfig的处理
- 感知组件是直接调用了
RegisterSenseClass
函数,该函数上面已经说过,不再赘述 - 也就是说与刺激源不同的是感知组件没有把组件的持有者注册给感知系统,因为是感知一方,除非感知配置开启了把所有Pawn注册为刺激源的选项
感知系统数据更新及与刺激源,感知组件三者间的数据交互
刺激源数据的更新(添加及删除刺激源数据)
- 刺激源数据的添加:
- 在感知组件的
Tick
函数里调用PerformSourceRegistation
函数,查找注册的Senses里与SourcesToRegister
数据成员里关联的Sense,然后把该SourceActor通过Sense的RegisterSource
函数添加到Sense感知配置实例里去,以便后续处理 - 绑定
StimuliSourceEndPlayDelegate
委托用于在SourceActor结束玩法时取消该Actor注册的刺激信息(比如下线,死亡等) - 添加一个刺激源数据
FPerceptionStimuliSource
到RegisteredStimuliSources结构里,同时调用RelevantSenses
的AcceptChannel
函数来设置刺激源针对于该Sense的白名单(这个白名单的用途是说明该SourceActor对该类型的感知配置有效) - 重置SourcesToRegister
- 剔除刺激源Actor的操作
- 这些操作来自于三方面的调用:感知组件执行
Cleanup
操作;刺激源组件主动调用取消注册函数;感知系统委托回调OnPerceptionStimuliSourceEndPlay
- 这些操作都是调用了
UnregisterSource
函数,该函数操作如下:
- 查找
RegisteredStimuliSources
里的刺激源结构成员,然后根据传入Sense的有效性分为两种情况处理,首先是有效的情况下,说明只针对某一类型的Sense进行处理,则首先检查刺激源中的通道白名单,有效则说明该Sense里有该SourceActor,则调用Sense的UnregisterSource
函数来做删除处理, 接着调用刺激源数据里的RelevantSenses
的FilterOutChannel
函数,剔除掉SourceActor对该Sense配置的响应(拉入黑名单) - 如果无效则需要考虑迭代所有的Sense了,然后根据与指定Sense的剔除操作相同,首先检查刺激源白名单,然后剔除,最后调用刺激源
RelevantSenses
的Clear
函数清空对所有Sense的响应 - 如果刺激源的
RelevanttSenses
是被清空了,则说明是该SourceActor已经对感知系统无效了,则从RegisteredStimuliSoruces
里移除,同时在SourceActor的OnEndPlay
里取消StimuliSourceEndPlayDelegate
的委托注册,因为SourceActor主动撤销了,而不需要等到OnEndPlay
- 从
SourceToRegister
待处理的刺激源数据里移除掉关于该SourceActor的注册数据
- 感知Listener的更新
- 之前在注册感知配置的时候是一种添加方式
- 还有一种方式是请求更新,主要是调用
RequestStimuliListenerUpdate
函数,而该函数主要调用了UpdateListener
函数 - 感知系统中的
OnListenerConfigUpdated
函数用于处理感知组件中指定SenseConfig变更时的操作,主要调用已注册的Sense里的配置更新回调
- 感知的移除
- 感知的移除主要来自于两个方面,一个是感知组件执行清除操作时对感知系统
UnregisterSource
的调用;另一个是来自于感知系统Tick
函数里来自于Listener的有效性检查 UnregisterSource
函数迭代了注册的Senses
,Listener中是否包含该Sense的判断来调用Sense里的OnListenerRemoved
函数
- 刺激源数据的处理
- 理解这个问题之前首先要搞清楚的几个感知系统里的成员变量:
PerceptionAgingRate
该属性决定了多久执行一次感知系统对刺激源信息的处理,默认为0.3f秒,可配置NextStimuliAgingTick
该属性标示了下一次要执行刺激源留存时间计算的时间,由当前系统时间加上PerceptionAgingRate
得出PerceptionAgingRate + (CurrentTime - NextStimuliAgingTick)
不难看出这个是用来计算出上一次执行到现在真正过去的时间,这个计算结果用于处理刺激源信息里Age计算
- 在感知系统的Tick函数里,通过
NextStimuliAgingTick
检查来决定是否对刺激源信息进行处理,在处理的时候调用感知系统里的AgeStimuli
,该函数主要是迭代注册的ListenerContainer
,而最终会调用感知组件里的AgeStimuli
这个函数用来检索感知组件上的感知数据PerceptualData
里的每一个感知数据,然后迭代次信息上一次获取的刺激源信息LastSensedStimuli
,计算Age加上当前距上一次计算过去的时间,看是否达到间隙留存时间上限,如果已达到留存时间则则给该刺激源信息打上过期标签,并调用RegisterStimulus
函数添加到StimulusToProcess
结构里,该结构在ProcessStimuli
函数处理数据时调用,最终需要说明的是当AgeStimuli
返回的结果为true时,会给FPerceptionListener
打上刺激源待处理标签,在处理刺激源消息时会检查该标签以此决定是否要处理刺激消息更新 - 那么有哪些情况会更新处理刺激源信息哪?
- 在感知系统的Tick里处理的了三种情况:1. 刺激源信息里有数据达到留存时间过期标识;2. Senses里有Sense到期Tick;3. 有Listener处于过期状态需要处理
- 当有以上三种情况发生时,会调用感知组件的
ProcessStimuli
函数进行刺激更新数据处理
ProcessStimuli
的处理内容
ProcessStimuli
里主要是对待处理的StimuliToProcess
进行迭代处理,处理的数据会被添加到PerceptualData
里,PerceptualData
里存放的是已被感知的数据,而StimuliToProcess
里的数据则是从其他地方收集来的待处理数据,这些数据来源包括:PerceptualData
里留存时间达到上限重新投放到StimulusToProcess
待处理数据结构里,亦或是Sense
通过Update更新,检测到符合条件的数据通过RegisterStimulus
注册来的内容。ProcessStimuli
除了向PerceptualData
投递数据外,还涉及到对需要遗忘的感知源进行剔除的的操作,核心操作是通过HasAnyCurrentStimulus
函数进行判断,然后添加到临时的遗忘数组里,这个数组在最后会调用感知组件的ForgetActor
(执行流程伪代码:ForgetActor-> PerceptionSystem::OnListenerForgetActor->Sense::OnListenerForgetsActor)- 另外值得关注的是,两个多播委托的执行也是在该函数中完成的:
OnTargetPerceptionUpdated
和OnTargetPerceptionInfoUpdated
,执行的前提是使用者在这两个委托上绑定过函数
感知流程总结
- 首先是
UAIPerceptionComponent
和UAIPerceptionStimuliSourceComponent
向UAIPerceptionSystem
发起Sense的注册,这个注册流程是不分先后的,也没有时序要求。但是这个注册的Sense实例是有上限的,它受两部分的限制,第一部分是Sense的类型(你也可以理解为继承自UAISense的子类类型数量数量);第二部分来自于发起请求的两者中不同的类型数量。当检测到有相同的Sense Class时就不会再注册,最终所有的Senses实例保存在UAIPerceptionSystem
的Senses里。在注册的同时,刺激源一方还会把刺激源Actor数据注册到感知系统的的SourcesToRegister
里,这里的数据最终通过感知系统Tick
函数调用PerformSourceRegistation
函数注册到Sense实体类中去,最终完成被感知内容的添加。同理,感知组件最终通过调用感知系统的UpdateListener
函数把自身添加到感知系统的ListenerContainer
结构里,再通过Sense的OnNewListener
函数把新的感知对象添加到Sense中。完成两部分的数据添加后,是UAISense
发挥数据操作的时候了,它会根据自身规则在Update
函数里进行循环运算,然后把数据更新给指定的感知组件,感知组件接收到数据就可以进行处理了。UAISense
的Update
是由具体实现决定的,你会发现UAISense
里的Update
函数是空的。 - 其他比较重要的部分是刺激源Actor的离场及感知对象的离场数据更新,刺激源信息留存有效期数据更新,Sense的Tick处理等
感知中的细枝末节
- 如果配置一个具有相同类型的UAISenseConfig会怎样?
相同的UAISenseConfig不会被多次注册,后面添加的配置会替代前面配置过的相同配置,然后调用感知系统中的OnListenerConfigUpdated
- 刺激源数据达到过期时间意味着什么?
仅仅意味着刷新时间到了,会把该调数据通过调用RegisterStimulus
函数重新装填到StimulusToProcess
中,而StimulusToProcess
在感知组件的ProcessStimuli
函数中重新迭代,刷新感知对象数据 - Sense怎么做到刷新的?
这个在感知系统的Tick函数里可以找到答案,Sense的更新依赖于感知系统的Tick,首先调用Sense的ProgressTime
函数TimeUntilNextUpdate
每次减去上一帧的执行时间,如果小于0.f则说明更新时间到了,则会迭代感知系统中注册的Sense实例 - 刺激源数据怎么做过期计算的?
这个同样是依赖于感知系统的Tick函数完成的,前面已经提到过,通过World下的当前时间和上一次计算的时间进行计算的出真正的过去的消耗时间,然后调用感知组件中的AgeStimuli
函数计算时间并给过期内容打标签 - Sense实例怎么给感知组件返回感知到的刺激源Actor的?
主要是在Sense
的Update函数里调用感知组件的RegisterStimulus
或者通过感知系统的RegisterDelayedStimulus
函数转调RegisterStimulus
函数把数据塞到StimulusToProcess
里等待更新处理