Checkpoint 8

1. 统一调度收口为单点 Complete
2. 移除 Job 内部 Complete(),改为返回 JobHandle
3. 修复调度后 NativeList 安全冲突(关键)
  - 将 PrepareEnemyJobOutputBuffer/PrepareProjectileJobOutputBuffer/PrepareEnemySeparationJobBuffers 前置到 BuildInput
  - 互斥候选统计改为读取 _enemyJobInputs,不再在 Complete 前读取 _enemyJobOutputs
4. 新增 CP8 profiler markers
5. 新增回归用例(验证使用最新敌人移动结果构建碰撞候选)
This commit is contained in:
SepComet 2026-02-23 10:53:15 +08:00
parent e6d722ee1d
commit 1a45b513f2
6 changed files with 168 additions and 115 deletions

View File

@ -8,6 +8,9 @@ namespace CustomDebugger
public static readonly ProfilerMarker TickEnemies_BuildInput = new ProfilerMarker("TickEnemies.BuildInput"); public static readonly ProfilerMarker TickEnemies_BuildInput = new ProfilerMarker("TickEnemies.BuildInput");
public static readonly ProfilerMarker TickEnemies_MoveSeparation = new ProfilerMarker("TickEnemies.MoveSeparation"); public static readonly ProfilerMarker TickEnemies_MoveSeparation = new ProfilerMarker("TickEnemies.MoveSeparation");
public static readonly ProfilerMarker TickEnemies_StateUpdate = new ProfilerMarker("TickEnemies.StateUpdate"); public static readonly ProfilerMarker TickEnemies_StateUpdate = new ProfilerMarker("TickEnemies.StateUpdate");
public static readonly ProfilerMarker TickEnemies_Schedule = new ProfilerMarker("TickEnemies.Schedule");
public static readonly ProfilerMarker TickEnemies_Complete = new ProfilerMarker("TickEnemies.Complete");
public static readonly ProfilerMarker TickEnemies_MainThreadCommit = new ProfilerMarker("TickEnemies.MainThreadCommit");
public static readonly ProfilerMarker TickEnemies_WriteBack = new ProfilerMarker("TickEnemies.WriteBack"); public static readonly ProfilerMarker TickEnemies_WriteBack = new ProfilerMarker("TickEnemies.WriteBack");
public static readonly ProfilerMarker Collision_BuildQueries = new("Collision.BuildQueries"); public static readonly ProfilerMarker Collision_BuildQueries = new("Collision.BuildQueries");
public static readonly ProfilerMarker Collision_BuildBuckets = new("Collision.BuildBuckets"); public static readonly ProfilerMarker Collision_BuildBuckets = new("Collision.BuildBuckets");

View File

@ -133,9 +133,45 @@ namespace Simulation
return; return;
} }
JobHandle enemyMovementHandle = default;
JobHandle projectileMovementHandle = default;
JobHandle enemySeparationHandle = default;
bool hasEnemySeparationJob = false;
bool hasEnemySeparationCandidates = false;
int enemySeparationCount = 0;
float enemySeparationMaxRadius = 0.45f;
using (CustomProfilerMarker.TickEnemies_BuildInput.Auto()) using (CustomProfilerMarker.TickEnemies_BuildInput.Auto())
{ {
SyncSimulationToJobInput(); SyncSimulationToJobInput();
int enemyCount = _enemyJobInputs.Length;
int projectileCount = _projectileJobInputs.Length;
PrepareEnemyJobOutputBuffer(enemyCount);
PrepareProjectileJobOutputBuffer(projectileCount);
enemySeparationCount = enemyCount;
for (int i = 0; i < enemyCount; i++)
{
EnemyJobInputData input = _enemyJobInputs[i];
if (!input.AvoidEnemyOverlap)
{
continue;
}
hasEnemySeparationCandidates = true;
float radius = input.EnemyBodyRadius > 0f ? input.EnemyBodyRadius : 0.45f;
if (radius > enemySeparationMaxRadius)
{
enemySeparationMaxRadius = radius;
}
}
if (hasEnemySeparationCandidates)
{
int separationBucketCapacity = Mathf.Max(128, enemyCount * 2);
PrepareEnemySeparationJobBuffers(enemyCount, separationBucketCapacity);
}
int projectileQueryCount = _projectiles.Count; int projectileQueryCount = _projectiles.Count;
int areaQueryCount = GetPendingAreaCollisionQueryCount(); int areaQueryCount = GetPendingAreaCollisionQueryCount();
int queryCount = projectileQueryCount + areaQueryCount; int queryCount = projectileQueryCount + areaQueryCount;
@ -148,48 +184,71 @@ namespace Simulation
using (CustomProfilerMarker.TickEnemies_StateUpdate.Auto()) using (CustomProfilerMarker.TickEnemies_StateUpdate.Auto())
{ {
ExecuteEnemyMovementJob(in context); enemyMovementHandle = ExecuteEnemyMovementJob(in context);
ExecuteProjectileMovementJob(in context); projectileMovementHandle = ExecuteProjectileMovementJob(in context);
}
JobHandle simulationHandle;
using (CustomProfilerMarker.TickEnemies_Schedule.Auto())
{
hasEnemySeparationJob = TryScheduleEnemySeparationForJobOutput(
in context,
enemyMovementHandle,
hasEnemySeparationCandidates,
enemySeparationCount,
enemySeparationMaxRadius,
out enemySeparationHandle);
JobHandle enemyHandle = hasEnemySeparationJob ? enemySeparationHandle : enemyMovementHandle;
simulationHandle = JobHandle.CombineDependencies(enemyHandle, projectileMovementHandle);
}
using (CustomProfilerMarker.TickEnemies_Complete.Auto())
{
simulationHandle.Complete();
} }
using (CustomProfilerMarker.TickEnemies_MoveSeparation.Auto()) using (CustomProfilerMarker.TickEnemies_MoveSeparation.Auto())
{ {
ApplyEnemySeparationForJobOutput(in context); if (hasEnemySeparationJob)
{
CommitEnemySeparationForJobOutput(enemySeparationCount);
}
BuildProjectileCollisionCandidates(); BuildProjectileCollisionCandidates();
} }
using (CustomProfilerMarker.TickEnemies_WriteBack.Auto()) using (CustomProfilerMarker.TickEnemies_WriteBack.Auto())
{
using (CustomProfilerMarker.TickEnemies_MainThreadCommit.Auto())
{ {
ApplyJobOutputToSimulation(); ApplyJobOutputToSimulation();
ResolveProjectileCollisionCandidatesMainThread(); ResolveProjectileCollisionCandidatesMainThread();
RecycleInactiveProjectiles(); RecycleInactiveProjectiles();
} }
}
MarkEnemyTargetSpatialIndexDirty(); MarkEnemyTargetSpatialIndexDirty();
BuildEnemyTargetSpatialIndexIfNeeded(); BuildEnemyTargetSpatialIndexIfNeeded();
} }
private void ExecuteEnemyMovementJob(in SimulationTickContext context) private JobHandle ExecuteEnemyMovementJob(in SimulationTickContext context)
{ {
int enemyCount = _enemyJobInputs.Length; int enemyCount = _enemyJobInputs.Length;
PrepareEnemyJobOutputBuffer(enemyCount);
if (enemyCount == 0) if (enemyCount == 0)
{ {
return; return default;
} }
if (context.DeltaTime <= 0f) if (context.DeltaTime <= 0f)
{ {
CopyEnemyInputToOutput(); CopyEnemyInputToOutput();
return; return default;
} }
float3 playerPosition = new float3(context.PlayerPosition.x, 0f, context.PlayerPosition.z); float3 playerPosition = new float3(context.PlayerPosition.x, 0f, context.PlayerPosition.z);
NativeArray<EnemyJobInputData> inputArray = _enemyJobInputs.AsArray(); NativeArray<EnemyJobInputData> inputArray = _enemyJobInputs.AsArray();
NativeArray<EnemyJobOutputData> outputArray = _enemyJobOutputs.AsArray(); NativeArray<EnemyJobOutputData> outputArray = _enemyJobOutputs.AsArray();
JobHandle handle;
if (_useBurstJobs) if (_useBurstJobs)
{ {
EnemyMovementBurstJob burstJob = new EnemyMovementBurstJob EnemyMovementBurstJob burstJob = new EnemyMovementBurstJob
@ -199,10 +258,9 @@ namespace Simulation
DeltaTime = context.DeltaTime, DeltaTime = context.DeltaTime,
PlayerPosition = playerPosition PlayerPosition = playerPosition
}; };
handle = burstJob.Schedule(enemyCount, 64); return burstJob.Schedule(enemyCount, 64);
} }
else
{
EnemyMovementJob job = new EnemyMovementJob EnemyMovementJob job = new EnemyMovementJob
{ {
Inputs = inputArray, Inputs = inputArray,
@ -210,10 +268,7 @@ namespace Simulation
DeltaTime = context.DeltaTime, DeltaTime = context.DeltaTime,
PlayerPosition = playerPosition PlayerPosition = playerPosition
}; };
handle = job.Schedule(enemyCount, 64); return job.Schedule(enemyCount, 64);
}
handle.Complete();
} }
private void CopyEnemyInputToOutput() private void CopyEnemyInputToOutput()
@ -238,42 +293,18 @@ namespace Simulation
} }
} }
private void ApplyEnemySeparationForJobOutput(in SimulationTickContext context) private bool TryScheduleEnemySeparationForJobOutput(in SimulationTickContext context, JobHandle dependency,
bool hasSeparationCandidates, int enemyCount, float maxRadius, out JobHandle separationHandle)
{ {
int enemyCount = _enemyJobOutputs.Length; separationHandle = dependency;
if (enemyCount == 0) if (enemyCount <= 0 || !hasSeparationCandidates)
{ {
return; return false;
}
bool hasSeparationCandidates = false;
float maxRadius = 0.45f;
for (int i = 0; i < enemyCount; i++)
{
EnemyJobOutputData output = _enemyJobOutputs[i];
if (!output.AvoidEnemyOverlap)
{
continue;
}
hasSeparationCandidates = true;
float radius = output.EnemyBodyRadius > 0f ? output.EnemyBodyRadius : 0.45f;
if (radius > maxRadius)
{
maxRadius = radius;
}
}
if (!hasSeparationCandidates)
{
return;
} }
float autoCellSize = maxRadius * 2f; float autoCellSize = maxRadius * 2f;
float configuredCellSize = _enemySeparationCellSize > 0f ? _enemySeparationCellSize : autoCellSize; float configuredCellSize = _enemySeparationCellSize > 0f ? _enemySeparationCellSize : autoCellSize;
float cellSize = Mathf.Max(0.1f, configuredCellSize); float cellSize = Mathf.Max(0.1f, configuredCellSize);
int bucketCapacity = Mathf.Max(128, enemyCount * 2);
PrepareEnemySeparationJobBuffers(enemyCount, bucketCapacity);
float3 playerPosition = new float3(context.PlayerPosition.x, 0f, context.PlayerPosition.z); float3 playerPosition = new float3(context.PlayerPosition.x, 0f, context.PlayerPosition.z);
float pushDamping = Mathf.Clamp(_enemySeparationPushDamping, 0f, 2f); float pushDamping = Mathf.Clamp(_enemySeparationPushDamping, 0f, 2f);
float maxStepScale = Mathf.Max(0.1f, _enemySeparationMaxStepScale); float maxStepScale = Mathf.Max(0.1f, _enemySeparationMaxStepScale);
@ -284,8 +315,6 @@ namespace Simulation
NativeArray<EnemyJobOutputData> separatedOutputArray = _enemyJobSeparationOutputs.AsArray(); NativeArray<EnemyJobOutputData> separatedOutputArray = _enemyJobSeparationOutputs.AsArray();
NativeArray<float2> previousPushes = _enemySeparationPreviousPushes.AsArray(); NativeArray<float2> previousPushes = _enemySeparationPreviousPushes.AsArray();
NativeArray<float2> currentPushes = _enemySeparationCurrentPushes.AsArray(); NativeArray<float2> currentPushes = _enemySeparationCurrentPushes.AsArray();
JobHandle buildHandle;
if (_useBurstJobs) if (_useBurstJobs)
{ {
BuildEnemySeparationBucketsBurstJob buildJob = new BuildEnemySeparationBucketsBurstJob BuildEnemySeparationBucketsBurstJob buildJob = new BuildEnemySeparationBucketsBurstJob
@ -294,22 +323,7 @@ namespace Simulation
Buckets = _enemySeparationBuckets.AsParallelWriter(), Buckets = _enemySeparationBuckets.AsParallelWriter(),
CellSize = cellSize CellSize = cellSize
}; };
buildHandle = buildJob.Schedule(enemyCount, 64); JobHandle buildHandle = buildJob.Schedule(enemyCount, 64, dependency);
}
else
{
BuildEnemySeparationBucketsJob buildJob = new BuildEnemySeparationBucketsJob
{
Inputs = inputArray,
Buckets = _enemySeparationBuckets.AsParallelWriter(),
CellSize = cellSize
};
buildHandle = buildJob.Schedule(enemyCount, 64);
}
JobHandle separationHandle;
if (_useBurstJobs)
{
EnemySeparationBurstJob separationJob = new EnemySeparationBurstJob EnemySeparationBurstJob separationJob = new EnemySeparationBurstJob
{ {
Inputs = inputArray, Inputs = inputArray,
@ -326,10 +340,17 @@ namespace Simulation
PushSmoothing = pushSmoothing PushSmoothing = pushSmoothing
}; };
separationHandle = separationJob.Schedule(enemyCount, 64, buildHandle); separationHandle = separationJob.Schedule(enemyCount, 64, buildHandle);
return true;
} }
else
BuildEnemySeparationBucketsJob nonBurstBuildJob = new BuildEnemySeparationBucketsJob
{ {
EnemySeparationJob separationJob = new EnemySeparationJob Inputs = inputArray,
Buckets = _enemySeparationBuckets.AsParallelWriter(),
CellSize = cellSize
};
JobHandle nonBurstBuildHandle = nonBurstBuildJob.Schedule(enemyCount, 64, dependency);
EnemySeparationJob nonBurstSeparationJob = new EnemySeparationJob
{ {
Inputs = inputArray, Inputs = inputArray,
Buckets = _enemySeparationBuckets, Buckets = _enemySeparationBuckets,
@ -344,10 +365,17 @@ namespace Simulation
UseTangentialInAttackRange = useTangentialInAttackRange, UseTangentialInAttackRange = useTangentialInAttackRange,
PushSmoothing = pushSmoothing PushSmoothing = pushSmoothing
}; };
separationHandle = separationJob.Schedule(enemyCount, 64, buildHandle); separationHandle = nonBurstSeparationJob.Schedule(enemyCount, 64, nonBurstBuildHandle);
return true;
}
private void CommitEnemySeparationForJobOutput(int enemyCount)
{
if (enemyCount <= 0)
{
return;
} }
separationHandle.Complete();
CommitEnemySeparationTemporalBuffers(enemyCount); CommitEnemySeparationTemporalBuffers(enemyCount);
for (int i = 0; i < enemyCount; i++) for (int i = 0; i < enemyCount; i++)
{ {

View File

@ -148,20 +148,18 @@ namespace Simulation
return expectedCount; return expectedCount;
} }
private void ExecuteProjectileMovementJob(in SimulationTickContext context) private JobHandle ExecuteProjectileMovementJob(in SimulationTickContext context)
{ {
int projectileCount = _projectileJobInputs.Length; int projectileCount = _projectileJobInputs.Length;
PrepareProjectileJobOutputBuffer(projectileCount);
if (projectileCount == 0) if (projectileCount == 0)
{ {
return; return default;
} }
if (context.DeltaTime <= 0f) if (context.DeltaTime <= 0f)
{ {
CopyProjectileInputToOutput(); CopyProjectileInputToOutput();
return; return default;
} }
float maxDistance = Mathf.Max(0f, _projectileMaxDistanceFromPlayer); float maxDistance = Mathf.Max(0f, _projectileMaxDistanceFromPlayer);
@ -172,7 +170,6 @@ namespace Simulation
NativeArray<ProjectileJobInputData> inputArray = _projectileJobInputs.AsArray(); NativeArray<ProjectileJobInputData> inputArray = _projectileJobInputs.AsArray();
NativeArray<ProjectileJobOutputData> outputArray = _projectileJobOutputs.AsArray(); NativeArray<ProjectileJobOutputData> outputArray = _projectileJobOutputs.AsArray();
JobHandle handle;
if (_useBurstJobs) if (_useBurstJobs)
{ {
ProjectileMovementBurstJob burstJob = new ProjectileMovementBurstJob ProjectileMovementBurstJob burstJob = new ProjectileMovementBurstJob
@ -184,10 +181,9 @@ namespace Simulation
MaxSqrDistanceFromPlayer = maxSqrDistanceFromPlayer, MaxSqrDistanceFromPlayer = maxSqrDistanceFromPlayer,
MaxVerticalOffsetFromPlayer = maxVerticalOffsetFromPlayer MaxVerticalOffsetFromPlayer = maxVerticalOffsetFromPlayer
}; };
handle = burstJob.Schedule(projectileCount, 64); return burstJob.Schedule(projectileCount, 64);
} }
else
{
ProjectileMovementJob job = new ProjectileMovementJob ProjectileMovementJob job = new ProjectileMovementJob
{ {
Inputs = inputArray, Inputs = inputArray,
@ -197,10 +193,7 @@ namespace Simulation
MaxSqrDistanceFromPlayer = maxSqrDistanceFromPlayer, MaxSqrDistanceFromPlayer = maxSqrDistanceFromPlayer,
MaxVerticalOffsetFromPlayer = maxVerticalOffsetFromPlayer MaxVerticalOffsetFromPlayer = maxVerticalOffsetFromPlayer
}; };
handle = job.Schedule(projectileCount, 64); return job.Schedule(projectileCount, 64);
}
handle.Complete();
} }
private void BuildProjectileCollisionCandidates() private void BuildProjectileCollisionCandidates()

View File

@ -535,6 +535,20 @@ namespace Simulation.Tests.Editor
Assert.That(GetCollisionCandidateCount(), Is.GreaterThan(0)); Assert.That(GetCollisionCandidateCount(), Is.GreaterThan(0));
} }
[Test]
public void TickProjectiles_BuildsCollisionCandidates_WithLatestEnemyMovement_WhenJobSimulationEnabled()
{
SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true });
UpsertEnemy(CreateEnemy(entityId: 5211, position: new Vector3(2f, 0f, 0f), speed: 1f, attackRange: 0.1f));
UpsertProjectile(CreateProjectile(entityId: 5212, position: new Vector3(1f, 0f, 0f),
forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 2f, age: 0f, active: true,
remainingLifetime: 2f, state: 0));
InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: Vector3.zero);
Assert.That(GetCollisionCandidateCount(), Is.GreaterThan(0));
}
[Test] [Test]
public void TickProjectiles_ExpiresAfterCollisionCandidateConsumed_WhenJobSimulationEnabled() public void TickProjectiles_ExpiresAfterCollisionCandidateConsumed_WhenJobSimulationEnabled()
{ {

View File

@ -565,6 +565,21 @@ namespace Simulation.Tests.PlayMode
yield break; yield break;
} }
[UnityTest]
public IEnumerator TickProjectiles_BuildsCollisionCandidates_WithLatestEnemyMovement_WhenJobSimulationEnabled()
{
SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true });
UpsertEnemy(CreateEnemy(entityId: 5511, position: new Vector3(2f, 0f, 0f), speed: 1f, attackRange: 0.1f));
UpsertProjectile(CreateProjectile(entityId: 5512, position: new Vector3(1f, 0f, 0f),
forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 2f, age: 0f, active: true,
remainingLifetime: 2f, state: 0));
InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: Vector3.zero);
Assert.That(GetCollisionCandidateCount(), Is.GreaterThan(0));
yield break;
}
[UnityTest] [UnityTest]
public IEnumerator TickProjectiles_ExpiresAfterCollisionCandidateConsumed_WhenJobSimulationEnabled() public IEnumerator TickProjectiles_ExpiresAfterCollisionCandidateConsumed_WhenJobSimulationEnabled()
{ {

View File

@ -156,13 +156,13 @@
- 建立命中事件缓冲区,统一在主线程提交表现层事件。 - 建立命中事件缓冲区,统一在主线程提交表现层事件。
- 完成标准:命中结果与现有逻辑一致,候选数量与耗时显著下降。 - 完成标准:命中结果与现有逻辑一致,候选数量与耗时显著下降。
- [ ] Checkpoint 7Burst 策略落地与热路径约束 - [x] Checkpoint 7Burst 策略落地与热路径约束
- 热路径 Job 全部添加 `[BurstCompile]`,并在 Burst Inspector 确认已生效。 - 热路径 Job 全部添加 `[BurstCompile]`,并在 Burst Inspector 确认已生效。
- 清理 Job 内不兼容写法托管分配、虚调用、LINQ、异常路径热调用。 - 清理 Job 内不兼容写法托管分配、虚调用、LINQ、异常路径热调用。
- 数学计算统一迁移到 `Unity.Mathematics` - 数学计算统一迁移到 `Unity.Mathematics`
- 完成标准:核心 Job 均由 Burst 编译,且无安全检查错误/降级回 Mono 的关键路径。 - 完成标准:核心 Job 均由 Burst 编译,且无安全检查错误/降级回 Mono 的关键路径。
- [ ] Checkpoint 8主线程职责收口与调度稳定 - [x] Checkpoint 8主线程职责收口与调度稳定
- 明确主线程只做输入采样、状态切换、UI 同步、实体显隐、最终写回。 - 明确主线程只做输入采样、状态切换、UI 同步、实体显隐、最终写回。
- 统一 `Schedule -> Dependency Combine -> Complete` 位置,防止隐式同步抖动。 - 统一 `Schedule -> Dependency Combine -> Complete` 位置,防止隐式同步抖动。
- 清理战斗帧中不必要的主线程循环(尤其逐实体逻辑)。 - 清理战斗帧中不必要的主线程循环(尤其逐实体逻辑)。