mirror of
https://github.com/Facepunch/sbox-public.git
synced 2026-04-26 17:29:17 -04:00
Add `DeferGeneration` property to skip navmesh generation during scene load, allowing tiles to be generated and unloaded on demand at runtime. New public API: - `NavMesh.DeferGeneration` - skip tile generation on load - `NavMesh.RequestTileGeneration`/`RequestTilesGeneration` - fire-and-forget via cache loop - `NavMesh.UnloadTile`/`UnloadTiles` - remove tiles from the navmesh
396 lines
10 KiB
C#
396 lines
10 KiB
C#
using DotRecast.Detour;
|
|
using Sandbox.Navigation.Generation;
|
|
using System.Collections.Concurrent;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace Sandbox.Navigation;
|
|
|
|
public sealed partial class NavMesh
|
|
{
|
|
internal static int HeightFieldGenerationThreadCount
|
|
{
|
|
get
|
|
{
|
|
return Math.Max( 2, Environment.ProcessorCount - 1 );
|
|
}
|
|
}
|
|
|
|
internal static int NavMeshGenerationThreadCount
|
|
{
|
|
get => HeightFieldGenerationThreadCount;
|
|
}
|
|
|
|
internal class GeneratorPool<T> : IDisposable where T : class, IDisposable, new()
|
|
{
|
|
public GeneratorPool( int poolSize )
|
|
{
|
|
for ( int i = 0; i < poolSize; i++ )
|
|
{
|
|
var generator = new T();
|
|
_pool.Enqueue( generator );
|
|
}
|
|
}
|
|
|
|
public T Get()
|
|
{
|
|
if ( _pool.TryDequeue( out var o ) )
|
|
return o;
|
|
|
|
return new T();
|
|
}
|
|
|
|
public void Return( T obj )
|
|
{
|
|
_pool.Enqueue( obj );
|
|
}
|
|
|
|
ConcurrentQueue<T> _pool = new();
|
|
|
|
public void Dispose()
|
|
{
|
|
while ( _pool.TryDequeue( out var o ) )
|
|
{
|
|
o.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
static internal GeneratorPool<HeightFieldGenerator> HeightFieldGeneratorPool = new( HeightFieldGenerationThreadCount );
|
|
static internal GeneratorPool<NavMeshGenerator> NavMeshGeneratorPool = new( NavMeshGenerationThreadCount );
|
|
|
|
/// <summary>
|
|
/// Generates or regenerates the navmesh tile at the given world position.
|
|
/// This function is thread safe but can only be called from the main thread.
|
|
/// </summary>
|
|
///
|
|
/// <remarks>
|
|
/// While most of the generation happens in parallel, this function also requires some time on the main thread.
|
|
/// If you need to update many tiles, consider spreading the updates accross multiple frames.
|
|
/// </remarks>
|
|
public async Task GenerateTile( PhysicsWorld world, Vector3 worldPosition )
|
|
{
|
|
ThreadSafe.AssertIsMainThread();
|
|
|
|
if ( !IsEnabled ) return;
|
|
|
|
var tilePosition = WorldPositionToTilePosition( worldPosition );
|
|
var tile = tileCache.GetOrAddTile( tilePosition );
|
|
|
|
var generatorConfig = CreateTileGenerationConfig( tile.TilePosition );
|
|
var heightFieldGenerator = HeightFieldGeneratorPool.Get();
|
|
heightFieldGenerator.Init( generatorConfig );
|
|
heightFieldGenerator.CollectGeometry( this, world, generatorConfig.Bounds );
|
|
|
|
var data = await Task.Run( () =>
|
|
{
|
|
CompactHeightfield heightField = null;
|
|
try
|
|
{
|
|
heightField = heightFieldGenerator.Generate();
|
|
|
|
tile.SetCachedHeightField( heightField );
|
|
|
|
if ( heightField == null )
|
|
{
|
|
return null;
|
|
}
|
|
|
|
tile.HeightfieldBuildComplete();
|
|
|
|
return tile.BuildNavmesh( heightField, generatorConfig, this );
|
|
}
|
|
finally
|
|
{
|
|
HeightFieldGeneratorPool.Return( heightFieldGenerator );
|
|
heightField?.Dispose();
|
|
}
|
|
} );
|
|
|
|
if ( data != null )
|
|
{
|
|
LoadTileOnMainThread( tile, data );
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates or regenerates the navmesh tiles overlapping with the given bounds.
|
|
/// This function is thread safe but can only be called from the main thread.
|
|
/// </summary>
|
|
///
|
|
/// <remarks>
|
|
/// While most of the generation happens in parallel, this function also requires some time on the main thread.
|
|
/// If you need to update many tiles, consider spreading the updates accross multiple frames.
|
|
/// </remarks>
|
|
public async Task GenerateTiles( PhysicsWorld world, BBox bounds )
|
|
{
|
|
ThreadSafe.AssertIsMainThread();
|
|
|
|
if ( !IsEnabled ) return;
|
|
|
|
var minMaxTileCoords = CalculateMinMaxTileCoords( bounds );
|
|
|
|
if ( minMaxTileCoords.Width <= 0 || minMaxTileCoords.Height <= 0 ) return;
|
|
|
|
var tilesToProcess = new List<NavMeshTile>( minMaxTileCoords.Width * minMaxTileCoords.Height );
|
|
for ( int x = minMaxTileCoords.Left; x <= minMaxTileCoords.Right; x++ )
|
|
{
|
|
for ( int y = minMaxTileCoords.Top; y <= minMaxTileCoords.Bottom; y++ )
|
|
{
|
|
tilesToProcess.Add( tileCache.GetOrAddTile( new Vector2Int( x, y ) ) );
|
|
}
|
|
}
|
|
|
|
var maxConcurrency = Math.Max( 1, HeightFieldGenerationThreadCount );
|
|
using var concurrencySemaphore = new SemaphoreSlim( maxConcurrency, maxConcurrency );
|
|
var generationTasks = new List<Task>( tilesToProcess.Count );
|
|
|
|
foreach ( var tile in tilesToProcess )
|
|
{
|
|
generationTasks.Add( ProcessTileAsync( tile ) );
|
|
}
|
|
|
|
await Task.WhenAll( generationTasks );
|
|
|
|
async Task ProcessTileAsync( NavMeshTile tile )
|
|
{
|
|
await concurrencySemaphore.WaitAsync().ConfigureAwait( false );
|
|
try
|
|
{
|
|
await Task.Run( async () =>
|
|
{
|
|
var generatorConfig = CreateTileGenerationConfig( tile.TilePosition );
|
|
|
|
try
|
|
{
|
|
CompactHeightfield heightField;
|
|
|
|
// Use baked heightfield data directly if available, avoiding expensive geometry collection
|
|
if ( tile.IsBakedHeightField && tile.IsHeightFieldValid )
|
|
{
|
|
heightField = tile.DecompressCachedHeightField();
|
|
}
|
|
else
|
|
{
|
|
var heightFieldGenerator = HeightFieldGeneratorPool.Get();
|
|
try
|
|
{
|
|
heightFieldGenerator.Init( generatorConfig );
|
|
|
|
await GameTask.MainThread();
|
|
heightFieldGenerator.CollectGeometry( this, world, generatorConfig.Bounds );
|
|
await GameTask.WorkerThread();
|
|
|
|
heightField = heightFieldGenerator.Generate();
|
|
}
|
|
finally
|
|
{
|
|
HeightFieldGeneratorPool.Return( heightFieldGenerator );
|
|
}
|
|
|
|
tile.SetCachedHeightField( heightField );
|
|
}
|
|
|
|
if ( heightField == null )
|
|
{
|
|
return;
|
|
}
|
|
|
|
tile.HeightfieldBuildComplete();
|
|
|
|
using ( heightField )
|
|
{
|
|
var tileMesh = tile.BuildNavmesh( heightField, generatorConfig, this );
|
|
|
|
await GameTask.MainThread();
|
|
LoadTileOnMainThread( tile, tileMesh );
|
|
}
|
|
}
|
|
catch ( Exception e )
|
|
{
|
|
// Swallow per-tile exceptions to avoid cancelling whole batch;
|
|
Log.Warning( $"Navmesh: Exception while generating tile {tile.TilePosition.x},{tile.TilePosition.y}" );
|
|
Log.Warning( e );
|
|
}
|
|
} ).ConfigureAwait( false );
|
|
}
|
|
finally
|
|
{
|
|
concurrencySemaphore.Release();
|
|
}
|
|
}
|
|
}
|
|
|
|
internal void LoadTileOnMainThread( NavMeshTile targetTile, DtMeshData data )
|
|
{
|
|
ThreadSafe.AssertIsMainThread();
|
|
|
|
// Tile may have been evicted (via UnloadTile) while an async build was in flight.
|
|
// Don't re-add it to the navmesh if it's no longer in the cache.
|
|
if ( !tileCache.HasTile( targetTile.TilePosition ) )
|
|
return;
|
|
|
|
var tileRef = navmeshInternal.GetTileRefAt( targetTile.TilePosition.x, targetTile.TilePosition.y, 0 );
|
|
|
|
if ( data == null )
|
|
{
|
|
if ( tileRef != default )
|
|
{
|
|
navmeshInternal.RemoveTile( tileRef );
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ( tileRef != default )
|
|
{
|
|
navmeshInternal.UpdateTile( data, 0 );
|
|
}
|
|
else
|
|
{
|
|
navmeshInternal.AddTile( data, 0, 0, out var _ );
|
|
}
|
|
|
|
targetTile.UpdateLinkStatus( this );
|
|
}
|
|
|
|
internal void UnloadTileOnMainThread( Vector2Int tilePosition )
|
|
{
|
|
ThreadSafe.AssertIsMainThread();
|
|
|
|
var tileRef = navmeshInternal.GetTileRefAt( tilePosition.x, tilePosition.y, 0 );
|
|
|
|
if ( tileRef != default )
|
|
{
|
|
navmeshInternal.RemoveTile( tileRef );
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes the navmesh tile at the given world position.
|
|
/// </summary>
|
|
public void UnloadTile( Vector3 worldPosition )
|
|
{
|
|
ThreadSafe.AssertIsMainThread();
|
|
|
|
if ( !IsEnabled ) return;
|
|
|
|
var tilePosition = WorldPositionToTilePosition( worldPosition );
|
|
UnloadTileOnMainThread( tilePosition );
|
|
tileCache.RemoveTile( tilePosition );
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes all navmesh tiles overlapping with the given bounds.
|
|
/// </summary>
|
|
public void UnloadTiles( BBox bounds )
|
|
{
|
|
ThreadSafe.AssertIsMainThread();
|
|
|
|
if ( !IsEnabled ) return;
|
|
|
|
var minMaxTileCoords = CalculateMinMaxTileCoords( bounds );
|
|
|
|
for ( int x = minMaxTileCoords.Left; x <= minMaxTileCoords.Right; x++ )
|
|
{
|
|
for ( int y = minMaxTileCoords.Top; y <= minMaxTileCoords.Bottom; y++ )
|
|
{
|
|
var tilePosition = new Vector2Int( x, y );
|
|
UnloadTileOnMainThread( tilePosition );
|
|
tileCache.RemoveTile( tilePosition );
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Queues the navmesh tile at the given world position for incremental generation
|
|
/// over subsequent frames. Fire-and-forget alternative to <see cref="GenerateTile"/>.
|
|
/// </summary>
|
|
public void RequestTileGeneration( Vector3 worldPosition )
|
|
{
|
|
ThreadSafe.AssertIsMainThread();
|
|
|
|
if ( !IsEnabled ) return;
|
|
|
|
var tilePosition = WorldPositionToTilePosition( worldPosition );
|
|
var tile = tileCache.GetOrAddTile( tilePosition );
|
|
tile.RequestFullRebuild();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Queues all navmesh tiles overlapping with the given bounds for incremental generation
|
|
/// over subsequent frames. Fire-and-forget alternative to <see cref="GenerateTiles"/>.
|
|
/// </summary>
|
|
public void RequestTilesGeneration( BBox bounds )
|
|
{
|
|
ThreadSafe.AssertIsMainThread();
|
|
|
|
if ( !IsEnabled ) return;
|
|
|
|
var minMaxTileCoords = CalculateMinMaxTileCoords( bounds );
|
|
|
|
for ( int x = minMaxTileCoords.Left; x <= minMaxTileCoords.Right; x++ )
|
|
{
|
|
for ( int y = minMaxTileCoords.Top; y <= minMaxTileCoords.Bottom; y++ )
|
|
{
|
|
var tile = tileCache.GetOrAddTile( new Vector2Int( x, y ) );
|
|
tile.RequestFullRebuild();
|
|
}
|
|
}
|
|
}
|
|
|
|
internal Vector2Int WorldPositionToTilePosition( Vector3 worldPosition )
|
|
{
|
|
var tileLocationFloat = (worldPosition - TileOrigin) / TileSizeWorldSpace;
|
|
return new Vector2Int( (int)tileLocationFloat.x, (int)tileLocationFloat.y );
|
|
}
|
|
|
|
internal Vector3 TilePositionToWorldPosition( Vector2Int tilePosition )
|
|
{
|
|
return TileOrigin + new Vector3( tilePosition.x, tilePosition.y, 0 ) * TileSizeWorldSpace + TileSizeWorldSpace * 0.5f;
|
|
}
|
|
|
|
internal RectInt CalculateMinMaxTileCoords( BBox bounds )
|
|
{
|
|
var clampedMins = Vector3.Max( bounds.Mins, Bounds.Mins );
|
|
var clampedMaxs = Vector3.Min( bounds.Maxs, Bounds.Maxs );
|
|
|
|
var coordMin = WorldPositionToTilePosition( clampedMins );
|
|
var coordMax = WorldPositionToTilePosition( clampedMaxs );
|
|
|
|
return new RectInt( coordMin, coordMax - coordMin );
|
|
}
|
|
|
|
internal BBox CalculateTileBounds( Vector2Int tilePosition )
|
|
{
|
|
return BBox.FromPositionAndSize( TilePositionToWorldPosition( tilePosition ), TileSizeWorldSpace );
|
|
}
|
|
|
|
internal Config CreateTileGenerationConfig( Vector2Int tilePosition )
|
|
{
|
|
var tileBoundsWorld = CalculateTileBounds( tilePosition );
|
|
|
|
// When using custom bounds, clamp the per-tile Z range so geometry outside
|
|
// the specified vertical extent is excluded from heightfield generation.
|
|
if ( CustomBounds )
|
|
{
|
|
tileBoundsWorld.Mins = tileBoundsWorld.Mins.WithZ( Math.Max( tileBoundsWorld.Mins.z, Bounds.Mins.z ) );
|
|
tileBoundsWorld.Maxs = tileBoundsWorld.Maxs.WithZ( Math.Min( tileBoundsWorld.Maxs.z, Bounds.Maxs.z ) );
|
|
}
|
|
|
|
var cfg = Config.CreateValidatedConfig(
|
|
tilePosition,
|
|
tileBoundsWorld,
|
|
CellSize,
|
|
CellHeight,
|
|
AgentHeight,
|
|
AgentRadius,
|
|
AgentStepSize,
|
|
AgentMaxSlope
|
|
);
|
|
|
|
return cfg;
|
|
}
|
|
}
|