ジョブシステムで、徘徊するキャラクターにプレイヤーが見えているかを判定してみました。レイキャストはRaycastCommandを使って分散処理します。
概要
シーンに10000体のナビメッシュエージェントを徘徊させます。エージェントからプレイヤーにレイを飛ばし、レイが当たったコライダーのインスタンスIDやエージェントの前方向との内積を使って、エージェントの視界内にプレイヤーが含まれているかを判定します。
RaycastCommandの作成や、内積の計算もジョブを発行して処理してみました。
シーン
シーンにはレイを投げるためのターゲットが配置されています。
スクリプトにはキャラクターのプレハブ、シーンのターゲット、ターゲットが見えているときに置き換えるマテリアルがアタッチされています。
スクリプトで10000体のキャラクターをインスタンス化します。
void Start()
{
agentMeshRenderers = new MeshRenderer[10000];
// 10000体のエージェントをインスタンス化
int index = 0;
for (int x = -50; x < 50; x++)
{
for (int z = -50; z < 50; z++)
{
agentMeshRenderers[index] = Instantiate(agentPrefab, new Vector3(x * 1.5f, 0f, z * 1.5f), Quaternion.identity).GetComponent<MeshRenderer>();
index++;
}
}
// デフォルトのマテリアルを取得
defaultMaterial = agentMeshRenderers[0].sharedMaterial;
}
通常のレイキャスト
まずはPhysics.Raycastを使います。シーンの全てのキャラクターに対して、ターゲットへレイを飛ばし、レイと視線方向の内積を計算しています。視界内にターゲットが含まれていれば、赤い色のマテリアルに変更し、それ以外ではデフォルトのマテリアルにしています。
void PerformStandardRaycasts()
{
for (int i = 0; i < agentMeshRenderers.Length; i++)
{
// ターゲットへの方向を計算
var dir = Vector3.Normalize(target.position - agentMeshRenderers[i].transform.position);
// レイを飛ばす
if (Physics.Raycast(agentMeshRenderers[i].transform.position, dir, out RaycastHit hit, 100f, 1 << 4))
{
// タグでプレイヤーを判別
if (hit.collider.CompareTag("Player"))
{
// レイの方向と視線方向の内積を計算
var dot = Vector3.Dot(dir, agentMeshRenderers[i].transform.forward);
if (dot >= 0f)
{
// マテリアルを変更
agentMeshRenderers[i].sharedMaterial = playerDetectedMaterial;
}
else
{
agentMeshRenderers[i].sharedMaterial = defaultMaterial;
}
}
else
{
agentMeshRenderers[i].sharedMaterial = defaultMaterial;
}
}
else
{
agentMeshRenderers[i].sharedMaterial = defaultMaterial;
}
}
}
プロファイラーを見ると、Updateメソッドにだいたい10.4msかかりました。
ジョブシステム
レイキャストを非同期に行うにはRaycastCommand.ScheduleBatchメソッドの引数に、RaycastCommandと結果のRaycastHitのNativeArrayを渡します。
レイの方向の計算に各キャラクターの位置を使うので、各キャラクターのデータを持つ構造体を定義しました。
// キャラクターのデータ
public struct WanderingAgent
{
// 現在の位置
public Vector3 position;
// 前方向
public Vector3 forward;
// ターゲットが見えているか
public bool isTargetInSight;
public void Proc(Vector3 targetPosition, bool rayHitTarget)
{
if (rayHitTarget)
{
// ターゲットへの方向を計算
var dir = Vector3.Normalize(targetPosition - position);
// 視線方向との内積を計算
var dot = Vector3.Dot(dir, forward);
// ターゲットが見えているかを判定
if (dot >= 0f)
{
isTargetInSight = true;
}
else
{
isTargetInSight = false;
}
}
else
{
isTargetInSight = false;
}
}
}
構造体は位置や前方向などを表すフィールドを持っています。レイキャストの結果を使って、ターゲットが見えているかを判定するためのメソッドもあります。
また、レイの方向の計算とRaycastCommandの作成を行うジョブを定義します。キャラクターの現在位置をレイの開始地点にしています。
[BurstCompile]
private struct PrepareRaycastCommandsJob : IJobFor
{
[ReadOnly]
public NativeArray<WanderingAgent> agents;
[NativeDisableParallelForRestriction]
public NativeArray<RaycastCommand> commands;
public int layerMask;
public Vector3 targetPosition;
public void Execute(int index)
{
// レイの開始地点
Vector3 position = this.agents[index].position;
// レイの方向
Vector3 direction = Vector3.Normalize(targetPosition - position);
// RaycastCommandを作成
this.commands[index] = new RaycastCommand(
position, direction, new QueryParameters(this.layerMask), 100);
}
}
レイキャストを実行した後に、キャラクターからターゲットが見えているかを判定するJobを定義します。
[BurstCompile]
public struct UpdateAgentSightJob : IJobParallelFor
{
public NativeArray<WanderingAgent> agents;
public Vector3 targetPosition;
public int colliderInstanceId;
[ReadOnly] public NativeArray<RaycastHitNative> results;
void IJobParallelFor.Execute(int index)
{
var agent = agents[index];
agent.Proc(targetPosition, results[index].ColliderInstanceId == colliderInstanceId);
agents[index] = agent;
}
}
キャラクターのデータを持つ構造体のメソッドを実行するときに、レイが当たったコライダーとターゲットのコライダーのインスタンスIDを比較してbool値を渡しています。
このJobにRaycastHitを含めるとエラーになるので、別の型の構造体に解釈して渡しています。
[StructLayout(LayoutKind.Sequential)]
public struct RaycastHitNative
{
public Vector3 Point;
public Vector3 Normal;
public uint FaceID;
public float Distance;
public Vector2 UV;
public int ColliderInstanceId;
}
Updateメソッドでは、毎フレームNativeArrayを作成しています。RaycastHitのNativeArrayのReinterpretメソッドを使って、別の構造体のNativeArrayに再解釈しています。
{
// NativeArrayを作成
NativeArray<RaycastHit> results = new(this.agentMeshRenderers.Length, Allocator.TempJob);
NativeArray<RaycastCommand> commands = new(this.agentMeshRenderers.Length, Allocator.TempJob);
NativeArray<WanderingAgent> agents = new(this.agentMeshRenderers.Length, Allocator.TempJob);
NativeArray<RaycastHitNative> nativeHits = results.Reinterpret<RaycastHitNative>();
キャラクターのデータのNativeArrayにシーンのキャラクターの位置と前方向を設定します。
// キャラクターのデータを設定
for (int i = 0; i < agents.Length; i++)
{
var trans = agentMeshRenderers[i].transform;
// 位置と方向を設定
var testAgent = agents[i];
testAgent.position = trans.position;
testAgent.forward = trans.forward;
agents[i] = testAgent;
}
RaycastCommandを作成するジョブをインスタンス化します。キャラクターとRaycastHitのNativeArrayやレイキャストに使うレイヤーマスク、ターゲットの位置を設定しています。
// RaycastCommandを作成するJob
PrepareRaycastCommandsJob prepareCommandsJob = new()
{
agents = agents,
commands = commands,
layerMask = 1 << 4,
targetPosition = target.position
};
レイキャストの結果を用いて、キャラクターからプレイヤーが見えているかを判定するJobを作成します。ターゲットのコライダーのインスタンスIDを設定しています。
// プレイヤーが見えているかを判定するJobを作成
UpdateAgentSightJob agentSightJob = new()
{
targetPosition = target.position,
agents = agents,
colliderInstanceId = target.GetComponent<Collider>().GetInstanceID(),
results = nativeHits
};
RaycastCommandを作成するジョブをスケジュールするには、IJobForExtensions.ScheduleParallelメソッドにRaycastCommandのNativeArrayを渡します。JobHandleが返ります。
JobHandle handle = prepareCommandsJob.ScheduleParallel(commands.Length, 64, default);
ジョブで行われるレイキャストをスケジュールするには、RaycastCommand.ScheduleBatchメソッドを呼びます。引数にRaycastCommandと結果のRaycastHitのNativeArrayを渡しています。
handle = RaycastCommand.ScheduleBatch(commands, results, 1, handle);
RaycastCommandを作成するジョブのJobHandleを依存関係として渡すことで、RaycastCommandを作成するジョブが完了してからレイキャストのジョブが行われます。
さらに、プレイヤーが見えているかを判定するジョブをスケジュールします。
handle = agentSightJob.Schedule(agentMeshRenderers.Length, 0, handle);
JobHandle.Completeメソッドでジョブの完了を待機します。
// メインスレッドと同期
handle.Complete();
キャラクターのデータの値によって、マテリアルを変更します。
// マテリアルを変更
for (int i = 0; i < agentMeshRenderers.Length; i++)
{
if (agents[i].isTargetInSight)
{
agentMeshRenderers[i].sharedMaterial = playerDetectedMaterial;
}
else
{
agentMeshRenderers[i].sharedMaterial = defaultMaterial;
}
}
最後にDisposeメソッドでNativeArrayの解放処理をします。RaycastHitのNativeArrayと再解釈したNativeArrayを両方Disposeしようとするとエラーがでました。
// 解放処理
results.Dispose();
commands.Dispose();
agents.Dispose();
// nativeHits.Dispose();
プロファイラー
プロファイラーをみると、Updateメソッドにかかった時間が6ms程度短くなっています。レイキャストの処理が複数のワーカースレッドで行われています。
レイキャストの前後でジョブが実行されています。BurstCompilerで色が変わり処理も早くなっています。
バッチ最適化
ジョブをスケジュールするメソッドの引数で、バッチ内の処理の最小数または最適な単位を指定できます。NativeArrayの長さである10000を渡すと全ての処理が一つのスレッドで行われました。
handle = RaycastCommand.ScheduleBatch(commands, results, 10000, handle);
5000にすると2つずつに分かれました。
RaycastHit
UpdateメソッドでRaycastHitのコライダーとプレイヤーのタグを比較してからジョブを実行した場合、2ms程度遅くなりました。
// メインスレッドと同期
handle.Complete();
// Jobのパラメーターを更新
agentSightJob.targetPosition = target.position;
agentSightJob.agents = agents;
// 結果のRaycastHitのタグを確認
for (int i = 0; i < agents.Length; i++)
{
var testAgent = agents[i];
var hitInfo = results[i];
if (hitInfo.collider != null)
{
testAgent.rayHitPlayer = hitInfo.collider.CompareTag("Player");
//testAgent.rayHitPlayer = hitInfo.collider.tag == "Player";
}
else
{
testAgent.rayHitPlayer = false;
}
agents[i] = testAgent;
}
// プレイヤーが見えているか判定するJobをスケジュール
handle = agentSightJob.Schedule(agentMeshRenderers.Length, 0);
// メインスレッドと同期
handle.Complete();
// マテリアルを変更
for (int i = 0; i < agentMeshRenderers.Length; i++)
{
if (agents[i].isPlayerInSight)
{
agentMeshRenderers[i].sharedMaterial = playerDetectedMaterial;
}
else
{
agentMeshRenderers[i].sharedMaterial = defaultMaterial;
}
}
CompareTag
タグを比較するときに、CompareTagメソッドではなく「==」演算子を使うと、大量のGC.Allocが発生しました。それによってさらに2ms程度の差ができました。
hitInfo.collider.tag == "Player"
参考:https://coffeebraingames.wordpress.com/2023/05/22/how-to-use-raycastcommand/
https://docs.unity3d.com/ja/2018.4/Manual/JobSystemNativeContainer.html
https://docs.unity3d.com/ja/2018.4/Manual/JobSystemJobDependencies.html
https://forum.unity.com/threads/how-to-process-raycasthit-within-a-job.914429/
https://docs.unity3d.com/ScriptReference/Unity.Collections.NativeArray_1.Reinterpret.html