Skip to content

Commit 1007e29

Browse files
authored
perf: Pre-compute harvester raycasts at frame start (#170)
Before this commit about 1/4 of the runtime of the various harvesters is spent running raycasts. This can also end up being much worse because occasionally a raycast will trigger a transform sync. Instead of doing this, we can use RaycastCommand.ScheduleBatch to queue all of these up at the start of FixedUpdate, and then individual consumers can look up the result later on when they need it. Consumers subscribe for a single ray trace per part module, with a transform that defines where the array originates from. The RaycastManager seems to take ~22us with 16 active drills, most of which is spent scheduling the 3 resulting jobs. The actual jobs themselves take about 50us, so this work should scale pretty well to much larger vessels if needed.
1 parent bdc26fb commit 1007e29

4 files changed

Lines changed: 363 additions & 0 deletions

File tree

Source/Addons/RaycastManager.cs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using Unity.Collections;
6+
using Unity.Jobs;
7+
using UnityEngine;
8+
using UnityEngine.Jobs;
9+
10+
namespace SystemHeat.Addons;
11+
12+
[DefaultExecutionOrder(-1)]
13+
[KSPAddon(KSPAddon.Startup.AllGameScenes, once: false)]
14+
internal class RaycastManager : MonoBehaviour
15+
{
16+
public static RaycastManager Instance { get; private set; }
17+
18+
struct RaycastParams
19+
{
20+
public float range;
21+
public int layerMask;
22+
}
23+
24+
const int DefaultCap = 128;
25+
26+
private TransformAccessArray transforms;
27+
private NativeArray<RaycastParams> args;
28+
private NativeArray<RaycastHit> hits;
29+
private readonly Dictionary<int, int> mapping = [];
30+
private readonly Dictionary<int, int> reverse = [];
31+
private JobHandle handle = default;
32+
33+
public void Register(PartModule module, Transform transform, float range, int layerMask = -5)
34+
{
35+
if (module == null || transform == null || this == null)
36+
return;
37+
38+
handle.Complete();
39+
40+
if (!enabled)
41+
enabled = true;
42+
43+
var moduleID = module.GetInstanceID();
44+
var arg = new RaycastParams
45+
{
46+
range = range,
47+
layerMask = layerMask
48+
};
49+
50+
// If the module is already registered then this overrides its existing
51+
// raycast request.
52+
if (mapping.TryGetValue(moduleID, out int index))
53+
{
54+
transforms[index] = transform;
55+
args[index] = arg;
56+
return;
57+
}
58+
59+
EnsureCapacity();
60+
61+
index = transforms.length;
62+
transforms.Add(transform);
63+
args[index] = arg;
64+
65+
mapping.Add(moduleID, index);
66+
reverse.Add(index, moduleID);
67+
}
68+
69+
public void Unregister(PartModule module)
70+
{
71+
if (module == null)
72+
return;
73+
74+
handle.Complete();
75+
76+
var moduleID = module.GetInstanceID();
77+
if (!mapping.TryGetValue(moduleID, out var index))
78+
return;
79+
80+
mapping.Remove(moduleID);
81+
reverse.Remove(index);
82+
transforms.RemoveAtSwapBack(index);
83+
84+
int last = transforms.length;
85+
if (reverse.TryGetValue(last, out var swappedID))
86+
{
87+
args[index] = args[last];
88+
if (hits.IsCreated && last < hits.Length)
89+
hits[index] = hits[last];
90+
91+
mapping[swappedID] = index;
92+
reverse.Remove(last);
93+
reverse[index] = swappedID;
94+
}
95+
}
96+
97+
/// <summary>
98+
/// Get the precomputed raycast hit for <paramref name="module"/>.
99+
/// </summary>
100+
/// <param name="module"></param>
101+
/// <returns><c>null</c> if there is no hit, and the hit otherwise.</returns>
102+
/// <remarks>
103+
/// <see cref="RaycastHit.collider" /> will be <c>null</c> if the raycast did
104+
/// not hit anything.
105+
/// </remarks>
106+
public RaycastHit? GetRaycastHit(PartModule module)
107+
{
108+
var moduleID = module.GetInstanceID();
109+
if (!mapping.TryGetValue(moduleID, out var index))
110+
return null;
111+
112+
if ((uint)index >= (uint)hits.Length)
113+
return null;
114+
115+
if (!handle.IsCompleted)
116+
handle.Complete();
117+
118+
return hits[index];
119+
}
120+
121+
void EnsureCapacity()
122+
{
123+
int length = transforms.length;
124+
if (length < transforms.capacity)
125+
return;
126+
127+
var newcap = Math.Max(length * 2, 128);
128+
var nt = new TransformAccessArray(newcap, desiredJobCount: 1);
129+
var na = new NativeArray<RaycastParams>(newcap, Allocator.Persistent, NativeArrayOptions.UninitializedMemory);
130+
131+
for (int i = 0; i < length; ++i)
132+
nt.Add(transforms[i]);
133+
NativeArray<RaycastParams>.Copy(args, na, length);
134+
135+
transforms.Dispose();
136+
args.Dispose();
137+
138+
transforms = nt;
139+
args = na;
140+
}
141+
142+
void Awake()
143+
{
144+
Instance = this;
145+
}
146+
147+
void OnDestroy()
148+
{
149+
if (Instance == this)
150+
Instance = null;
151+
}
152+
153+
void OnEnable()
154+
{
155+
transforms = new TransformAccessArray(DefaultCap, desiredJobCount: 1);
156+
args = new NativeArray<RaycastParams>(DefaultCap, Allocator.Persistent);
157+
}
158+
159+
void OnDisable()
160+
{
161+
try
162+
{
163+
if (!handle.IsCompleted)
164+
handle.Complete();
165+
}
166+
catch (Exception e)
167+
{
168+
Debug.LogException(e);
169+
}
170+
171+
transforms.Dispose();
172+
args.Dispose();
173+
if (hits.IsCreated)
174+
hits.Dispose();
175+
176+
transforms = default;
177+
args = default;
178+
hits = default;
179+
180+
mapping.Clear();
181+
reverse.Clear();
182+
}
183+
184+
void FixedUpdate()
185+
{
186+
if (!handle.IsCompleted)
187+
handle.Complete();
188+
189+
var count = mapping.Count;
190+
if (count == 0)
191+
{
192+
handle = default;
193+
return;
194+
}
195+
196+
var commands = new NativeArray<RaycastCommand>(count, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
197+
hits = new NativeArray<RaycastHit>(count, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
198+
199+
// RaycastCommand.ScheduleBatch reads maxHits immediately but the remaining
200+
// fields are not read until the job runs. So we need to initialize maxHits
201+
// immediately but the rest can happen in a job.
202+
for (int i = 0; i < count; ++i)
203+
commands[i] = new() { maxHits = 1 };
204+
205+
handle = new BuildCommandJob { args = args, commands = commands }
206+
.Schedule(transforms, handle);
207+
handle = RaycastCommand.ScheduleBatch(commands, hits, 64, handle);
208+
JobHandle.ScheduleBatchedJobs();
209+
}
210+
211+
void Update()
212+
{
213+
handle.Complete();
214+
215+
if (mapping.Count == 0)
216+
enabled = false;
217+
}
218+
219+
struct BuildCommandJob : IJobParallelForTransform
220+
{
221+
[ReadOnly]
222+
public NativeArray<RaycastParams> args;
223+
[WriteOnly]
224+
public NativeArray<RaycastCommand> commands;
225+
226+
public void Execute(int index, TransformAccess transform)
227+
{
228+
var arg = args[index];
229+
var command = new RaycastCommand
230+
{
231+
from = transform.position,
232+
direction = transform.rotation * Vector3.forward,
233+
distance = arg.range,
234+
layerMask = arg.layerMask,
235+
maxHits = 1
236+
};
237+
238+
commands[index] = command;
239+
}
240+
}
241+
}

Source/Modules/ModuleSystemHeatAsteroidHarvester.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using UnityEngine;
22
using KSP.Localization;
3+
using SystemHeat.Addons;
34
using Unity.Profiling;
45

56
namespace SystemHeat
@@ -39,6 +40,9 @@ public class ModuleSystemHeatAsteroidHarvester : ModuleAsteroidDrill
3940

4041
protected ModuleSystemHeat heatModule;
4142

43+
// Stock ModuleAsteroidDrill uses Physics.DefaultRaycastLayers (no mask).
44+
private const int ImpactLayerMask = -5;
45+
4246
private static readonly ProfilerMarker BaseFixedUpdateMarker = new("ModuleAsteroidDrill.FixedUpdate");
4347

4448
public override string GetInfo()
@@ -63,6 +67,13 @@ public void Start()
6367

6468
Utils.Log("[ModuleSystemHeatAsteroidHarvester] Setup completed", LogType.Modules);
6569
Fields["HarvesterEfficiency"].guiName = Localizer.Format("#LOC_SystemHeat_ModuleSystemHeatHarvester_Field_Efficiency", ConverterName);
70+
71+
RegisterImpactRaycast();
72+
}
73+
74+
void OnEnable()
75+
{
76+
RegisterImpactRaycast();
6677
}
6778

6879
public override void FixedUpdate()
@@ -92,6 +103,35 @@ void OnDisable()
92103
{
93104
heatModule?.AddFlux(moduleID, 0f, 0f, false);
94105
HarvesterEfficiency = "-";
106+
RaycastManager.Instance?.Unregister(this);
107+
}
108+
109+
void RegisterImpactRaycast()
110+
{
111+
if (!HighLogic.LoadedSceneIsFlight || impactTransformCache == null)
112+
return;
113+
RaycastManager.Instance?.Register(this, impactTransformCache, ImpactRange, ImpactLayerMask);
114+
}
115+
116+
protected override bool CheckForImpact()
117+
{
118+
if (string.IsNullOrEmpty(ImpactTransform) || impactTransformCache == null)
119+
return true;
120+
121+
var hit = RaycastManager.Instance?.GetRaycastHit(this);
122+
if (hit is not RaycastHit raycastHit)
123+
{
124+
// If we're not registered for whatever reason then do the raycast ourselves.
125+
var origin = impactTransformCache.position;
126+
if (!Physics.Raycast(new Ray(origin, impactTransformCache.forward), out raycastHit, ImpactRange, ImpactLayerMask))
127+
return false;
128+
}
129+
130+
var collider = raycastHit.collider;
131+
if (collider == null)
132+
return false;
133+
134+
return collider.gameObject.GetComponentUpwards<ModuleAsteroid>() != null;
95135
}
96136

97137
void FixedUpdateFlight()

Source/Modules/ModuleSystemHeatCometHarvester.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using KSP.Localization;
2+
using SystemHeat.Addons;
23
using Unity.Profiling;
4+
using UnityEngine;
35

46
namespace SystemHeat
57
{
@@ -38,6 +40,9 @@ public class ModuleSystemHeatCometHarvester : ModuleCometDrill
3840

3941
protected ModuleSystemHeat heatModule;
4042

43+
// Stock ModuleCometDrill uses Physics.DefaultRaycastLayers (no mask).
44+
private const int ImpactLayerMask = -5;
45+
4146
private static readonly ProfilerMarker BaseFixedUpdateMarker = new("ModuleCometDrill.FixedUpdate");
4247

4348
public override string GetInfo()
@@ -62,6 +67,13 @@ public void Start()
6267

6368
Utils.Log("[ModuleSystemHeatCometHarvester] Setup completed", LogType.Modules);
6469
Fields["HarvesterEfficiency"].guiName = Localizer.Format("#LOC_SystemHeat_ModuleSystemHeatHarvester_Field_Efficiency", ConverterName);
70+
71+
RegisterImpactRaycast();
72+
}
73+
74+
void OnEnable()
75+
{
76+
RegisterImpactRaycast();
6577
}
6678

6779
public override void FixedUpdate()
@@ -91,6 +103,40 @@ void OnDisable()
91103
{
92104
heatModule?.AddFlux(moduleID, 0f, 0f, false);
93105
HarvesterEfficiency = "-";
106+
RaycastManager.Instance?.Unregister(this);
107+
}
108+
109+
void RegisterImpactRaycast()
110+
{
111+
if (!HighLogic.LoadedSceneIsFlight || impactTransformCache == null)
112+
return;
113+
RaycastManager.Instance?.Register(this, impactTransformCache, ImpactRange, ImpactLayerMask);
114+
}
115+
116+
protected override bool CheckForImpact()
117+
{
118+
if (string.IsNullOrEmpty(ImpactTransform) || impactTransformCache == null)
119+
return true;
120+
121+
Collider collider;
122+
var hit = RaycastManager.Instance?.GetRaycastHit(this);
123+
if (hit != null)
124+
{
125+
collider = hit.Value.collider;
126+
}
127+
else
128+
{
129+
// If we're not registered for whatever reason then do the raycast ourselves.
130+
var origin = impactTransformCache.position;
131+
if (!Physics.Raycast(new Ray(origin, impactTransformCache.forward), out var fallback, ImpactRange, ImpactLayerMask))
132+
return false;
133+
collider = fallback.collider;
134+
}
135+
136+
if (collider == null)
137+
return false;
138+
139+
return collider.gameObject.GetComponentUpwards<ModuleComet>() != null;
94140
}
95141

96142
void FixedUpdateFlight()

0 commit comments

Comments
 (0)