查看原文
其他

使用摄像机和着色器让AI从视觉检测玩家

Unity Unity官方平台 2022-05-07

本文将由游戏开发者Kvin Drure分享,在游戏中使用摄像机和着色器,帮助AI从视觉上检测到玩家的存在。


在许多游戏中,光线投射通常是AI检测玩家的有效解决方案。但玩家时常被部分遮蔽,使简单的光线投射不足以检测到玩家。这种情况可通过对玩家边界进行多次光线投射来解决,但该方法对一些游戏有着很大限制,难以实现。

 

通常我们只需知道玩家是否可见,但有时我们要知道玩家可见部分占多少视觉空间。我的思路是使用着色器来检测玩家,让AI以类似人类玩家的方法执行检测,即使用渲染图像,并尝试通过读取像素来检测目标


下载示例

下面是Kvin Drure尝试使用摄像机和着色器让AI检测玩家所使用的Unity工程项目,项目使用Unity 2018.2开发。


在该项目中,我们可以使用ZQSD来移动角色,Ctrl或空格键来蹲下,这样对测试遮蔽效果很实用。在展示场景中有一些AI和小型关卡设计。

 

下载示例工程:

https://drive.google.com/open?id=1uXbT2I2Udze1Hxi8BfQnPOL4CK63cZYH


 百度云盘下载:

 https://pan.baidu.com/s/153R9wsZ_pPphu0L7kiB5vQ 

 提取码: uu7v 


为AI提供视觉

首先,AI需要一个摄像机获取渲染图像。我们要执行二次渲染,一次用于环境,另外一次用于玩家。


我们需要保存渲染结果到纹理中。在这一步似乎产生了一个难点,对比二个纹理来让AI检测玩家的可见像素会很困难。


因为忽略了一个关键点,我们并不需要渲染人类能够解读的纹理,像是人类玩家在屏幕上看到的图像。我们只需渲染出的纹理带有可由代码解读的信息:像素深度


我们不是为纹理每个的像素渲染最终像素颜色,而是写入像素深度。比较像素深度会让对比二个纹理的过程非常简单,轻松判断玩家的每个像素是被遮蔽还是可见。

 

在Unity中,摄像机可以使用替换着色器进行渲染。我们会使用自定义着色器来渲染对象,而不是原本的着色器。

 

这里有二种相关的方法:Camera.RenderWithShader和Camera.SetReplacementShader。


关于Unity中替换着色器的更多信息,详见Unity帮助手册:

https://docs.unity3d.com/Manual/SL-ShaderReplacement.html


下图是一个像素深度渲染的示例图。


对比渲染纹理

为了对比二个纹理,我们要进行一些处理。我们可以使用Unity Texture2D.GetPixels32方法在游戏代码中逐个像素地读取纹理。但这样使用CPU读取纹理会消耗太多时间。一个实时程序中,每一毫秒都非常重要,所以使用CPU的方法并不适合。

 

我们必须使用GPU,因为在CPU执行游戏逻辑时,GPU通常不进行处理。GPU适合并行化处理,在CPU为当前帧执行游戏逻辑时,如果再加上GPU上的多线程处理,则会非常理想了。

 

我们将使用Compute Shaders计算着色器以及计算缓冲区。计算缓冲区可以在CPU内存和GPU内存间共享数据,因此我们可以通过它访问计算着色器的数据。


在CPU方面,我们会使用下面的代码:

   /// <summary>
   /// 所有可视化检测会在协程中更新   

   /// 对纹理执行渲染,调度计算着色器,使用异步请求在GPU完成处理时获取计算缓冲区   

   /// </summary>
   /// <returns></returns>
   private IEnumerator CR_UpdateVisualDetection()
   {
       ComputeBuffer buffer = null;
       int[] computeBufferResult = null;
       AsyncGPUReadbackRequest request = new AsyncGPUReadbackRequest();

       do
       {
           // 检查请求是否完成或失败,以处理新的检测 

           if (request.done || request.hasError)
           {
               //请求成功完成

               if (request.done && !request.hasError)
               {
                   // 从计算缓冲区获取计算着色器函数的结果

                   computeBufferResult = request.GetData<int>().ToArray();

                   //释放计算缓冲区,进行垃圾回收

                   buffer.Release();

                   // 把计算缓冲区结果转换为可读格式(填充nb像素的比率 / 总nb像素的比率)

                   m_DetectionRatio = (float)computeBufferResult[0] / (TEXTURE_WIDTH * TEXTURE_HEIGHT);
               }

               //检查目标是否在锥形体中,如果不在,则不必执行可视化检测,因为目标完全无法被看到

               if (IsTargetInFrustum())
               {
                   // 从摄像机渲染纹理
                   if (m_VisualDetectionCamera != null)
                   {
                       for (int i = 0; i < 2; i++)
                       {
                           m_VisualDetectionCamera.Render(i);
                       }
                   }

                   // 创建计算缓冲区,在此脚本和计算缓冲区间分享数据 

                   buffer = new ComputeBuffer(1, 4);
                   computeBufferResult = new int[1];
                   buffer.SetData(computeBufferResult);
                   m_ComputeShader.SetBuffer(m_KernelIndex, "intBuffer", buffer);

                   // 在128*128线程调用计算着色器函数,因为着色器线程分配为[numthreads(32,32,1)] ,在渲染纹理上每个像素都有一个线程

                   // 32个线程的4组 * 32个线程的4组 * 1个线程的1组 

                   m_ComputeShader.Dispatch(m_KernelIndex, TEXTURE_WIDTH / 32, TEXTURE_HEIGHT / 32, 1);

                   // 在着色器完成执行时,执行异步请求来获取计算缓冲区

                   request = AsyncGPUReadback.Request(buffer);
               }
           }

           yield return null;
       }
       while (enabled);
   }


下面是计算着色器的代码:

// 每个#kernel会告诉编译哪个函数;我们可以拥有多个内核

#pragma kernel CountLowerDepth


//要对比的纹理

Texture2D<float3> GlobalGeometryTexture;

Texture2D<float3> TargetGeometryTexture;


// Int buffer用来计算目标几何体纹理较低深度像素的数量

RWStructuredBuffer<int> intBuffer;


// 计算纹理上较低深度像素的数量

[numthreads(32,32,1)]

void CountLowerDepth(uint3 id : SV_DispatchThreadID)

{

 // 把条件表达式转换为算术表达式

 int globalBlack = step(GlobalGeometryTexture[id.xy].r, 0.00001f); // GlobalGeometryTexture[id.xy].r == 0

 int targetNotBlack = step(0.00001f, TargetGeometryTexture[id.xy].r); // TargetGeometryTexture[id.xy].r > 0

 int lowerDepth = step(TargetGeometryTexture[id.xy].r, GlobalGeometryTexture[id.xy].r); // TargetGeometryTexture[id.xy].r < GlobalGeometryTexture[id.xy].r


 int caseA = step(2, globalBlack + targetNotBlack);

 int caseB = step(2 + caseA, targetNotBlack + lowerDepth);


 //增加带有最大结果的缓冲区int值(++ 如果OR条件为True)

 InterlockedAdd(intBuffer[0], max(caseA, caseB));

}


首先要知道,这里的计算着色器会调度为每个像素使用一个线程。

 

其次,如果我们想避免分支处理,必须处理算术计算,在此会使用Step函数。如果x >= y,Step(y, x)返回1,否则为0。这样做是为了检查像素或玩家纹理是否有比环境纹理像素更低的深度。

 

我们也在代码中检查了黑色像素的情况,这意味着该像素没有任何渲染内容,虽然实际上不会发生这种情况,但为了进行测试,我们可能会使用比较空的场景,不进行这种检查会导致返回错误的结果。

 

在InterlockedAdd函数中,当玩家像素可见时,我在缓冲区增大了int变量,使用这种原子加法函数来保证避免发生竞态条件。


获取计算着色器结果

现在分配好了计算着色器,对每个纹理像素使用一个线程执行计算着色器,我们还需要从CPU获取结果。我们可以使用计算缓冲区类就有GetData<T>(Array data)方法,需要使用Unity 2017及更高版本。


该方法的问题在于无法异步获取数据,它是以同步方法工作的,会让CPU等到着色器完成时才能访问内存,从而造成严重的CPU停顿。因为CPU还是得等待GPU,并行处理的效率不是非常高。

 

问题的关键在于使用异步请求,如果使用Unity 2017版本,Unity API里没有方法可以用来异步执行请求。但在Unity 2018.2中增加了一个新功能AsyncGPUReadback,我们只要使用请求,并等到请求完成再获取数据。


如果观察之前的代码,你会发现只要寥寥几行代码就能处理该部分:

AsyncGPUReadbackRequest request = new AsyncGPUReadbackRequest();


// ...


// 检查请求是否完成或失败,以处理新的检测

if (request.done || request.hasError)

{

 // 请求成功完成

 if (request.done && !request.hasError)

 {

  // ...

 }

 

 // ...


 //执行异步请求,在着色器执行完成时获取计算缓冲区

 request = AsyncGPUReadback.Request(buffer);

}

决定玩家在AI视觉中所占空间

有了检测结果,可以通过不同的逻辑来处理。我们也可以就直接使用观察到的像素数量,并检查在哪一步我们要开始让AI进行应对。

 

我的解决方案是获取玩家在AI视觉中占有的视觉空间百分比。我把观察到的像素量除以组成视觉的像素量,在这里也就是渲染纹理。

  // 把计算缓冲区结果转换为可读格式(填充的像素量比率 / 总像素量)

m_DetectionRatio = (float)computeBufferResult[0] / (TEXTURE_WIDTH * TEXTURE_HEIGHT);

结语

本文中的方法未必是实现该功能的最好方法,但它是通过假设尝试过的可行方法。如果你有更好的方法,欢迎与我们分享。

 

更多Unity精彩技术经验分享和谈论,尽在Unity Connect平台(Connect.unity.com)。

 

推荐阅读

 

官方活动

Unite Shanghai 2019

5月10日-12日上海,Unite大会强势回归。技术门票正在热销中,购票即获指定Asset Store资源商店精品21款资源的5折优惠券。

购票请访问:Unite2019.csdn.net



点击“阅读原文”访问Unity Connect

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存