Files
sbox-public/engine/Sandbox.Engine/Game/Navigation/Generation/HeightFieldGenerator.cs
Lorenz Junglas b83e2abafa Navmesh baking (#3981)
Adds disk persistence for compressed HeightFields to speed up navmesh loading.

Since we serialize the HeightField (not the final PolyMesh), loading still requires PolyMesh generation at runtime. This is intentional, HeightFields are needed for dynamic obstacles and areas.

Small/medium maps: Baking and loading both near-instant, storage ~few MBs
Very large maps (e.g. 4km × 4km): ~10s bake, ~8s load ~500mb

**Baking**

Serializes existing in-memory HeightField cache to disk with layered compression.

**Loading**

Eliminates geometry collection and HeightField generation on scene load.
PolyMesh generation still runs at runtime (allows dynamic areas/links).
2026-02-04 12:02:29 +01:00

227 lines
7.1 KiB
C#

using NativeEngine;
using System.Buffers;
using System.Runtime.InteropServices;
namespace Sandbox.Navigation.Generation;
[SkipHotload]
class HeightFieldGenerator : IDisposable
{
internal Vector3[] inputGeoVertices = null;
internal int[] inputGeoIndices = null;
internal int inputGeoVerticesCount = 0;
internal int inputGeoIndicesCount = 0;
float inputGeometryMaxZ;
float inputGeometryMinZ;
Config cfg;
Heightfield cachedHeightField;
CUtlVectorVector triangulationVertArrCache = CUtlVectorVector.Create( 0, 512 );
CUtlVectorUInt32 triangulationIndexArrCache = CUtlVectorUInt32.Create( 0, 1024 );
public bool IsEmpty => inputGeoVerticesCount < 3 || inputGeoIndicesCount < 3;
public void Dispose()
{
cachedHeightField?.Dispose();
cachedHeightField = null;
triangulationVertArrCache.DeleteThis();
triangulationIndexArrCache.DeleteThis();
if ( inputGeoVertices != null )
{
ArrayPool<Vector3>.Shared.Return( inputGeoVertices );
inputGeoVertices = null;
}
if ( inputGeoIndices != null )
{
ArrayPool<int>.Shared.Return( inputGeoIndices );
inputGeoIndices = null;
}
}
public void Init( Config config )
{
inputGeoVertices ??= ArrayPool<Vector3>.Shared.Rent( 2048 );
inputGeoIndices ??= ArrayPool<int>.Shared.Rent( 4096 );
inputGeoVerticesCount = 0;
inputGeoIndicesCount = 0;
inputGeometryMaxZ = float.MinValue;
inputGeometryMinZ = float.MaxValue;
cfg = config;
}
public void CollectGeometry( NavMesh navMesh, PhysicsWorld world, BBox tileBoundsWorld )
{
ThreadSafe.AssertIsMainThread();
if ( !world.IsValid() )
return;
var results = CQueryResult.Create();
world.native.Query( results, tileBoundsWorld, 0x07 );
// clear arrays
inputGeoVerticesCount = 0;
inputGeoIndicesCount = 0;
for ( int i = 0; i < results.Count(); i++ )
{
var shape = results.Element( i );
if ( !shape.IsValid() )
continue;
var body = shape.Body;
if ( !body.IsValid() )
continue;
if ( !navMesh.IsBodyRelevantForNavmesh( body ) )
continue;
AddGeometryFromPhysicsShape( shape );
}
results.DeleteThis();
}
internal void AddGeometryFromPhysicsShape( PhysicsShape shape )
{
triangulationVertArrCache.SetCount( 0 );
triangulationIndexArrCache.SetCount( 0 );
shape.native.GetTriangulationForNavmesh( triangulationVertArrCache, triangulationIndexArrCache, cfg.Bounds );
if ( inputGeoVerticesCount + triangulationVertArrCache.Count() > inputGeoVertices.Length )
{
var newVerts = ArrayPool<Vector3>.Shared.Rent( (inputGeoVerticesCount + triangulationVertArrCache.Count()) * 2 );
Array.Copy( inputGeoVertices, newVerts, inputGeoVerticesCount );
ArrayPool<Vector3>.Shared.Return( inputGeoVertices );
inputGeoVertices = newVerts;
}
if ( inputGeoIndicesCount + triangulationIndexArrCache.Count() > inputGeoIndices.Length )
{
var newIndices = ArrayPool<int>.Shared.Rent( (inputGeoIndicesCount + triangulationIndexArrCache.Count()) * 2 );
Array.Copy( inputGeoIndices, newIndices, inputGeoIndicesCount );
ArrayPool<int>.Shared.Return( inputGeoIndices );
inputGeoIndices = newIndices;
}
int startV = inputGeoVerticesCount;
for ( int i = 0; i < triangulationIndexArrCache.Count(); i += 3 )
{
// invert winding and increment by startV
var idx0 = triangulationIndexArrCache.Element( i + 0 );
var idx1 = triangulationIndexArrCache.Element( i + 1 );
var idx2 = triangulationIndexArrCache.Element( i + 2 );
inputGeoIndices[inputGeoIndicesCount++] = (int)idx0 + startV;
inputGeoIndices[inputGeoIndicesCount++] = (int)idx2 + startV;
inputGeoIndices[inputGeoIndicesCount++] = (int)idx1 + startV;
}
// Transform positions added to the vertex data
var bodyTransform = shape.Body.Transform;
for ( int v = 0; v < triangulationVertArrCache.Count(); ++v )
{
var vertexWorldPos = bodyTransform.PointToWorld( triangulationVertArrCache.Element( v ) );
if ( vertexWorldPos.z > inputGeometryMaxZ ) inputGeometryMaxZ = vertexWorldPos.z;
if ( vertexWorldPos.z < inputGeometryMinZ ) inputGeometryMinZ = vertexWorldPos.z;
inputGeoVertices[inputGeoVerticesCount++] = NavMesh.ToNav( vertexWorldPos );
}
}
public CompactHeightfield Generate()
{
var nverts = inputGeoVerticesCount;
var ntris = inputGeoIndicesCount / 3;
// Nothing todo bail
if ( nverts < 3 || ntris == 0 )
{
return null;
}
// Ideally we should also never arrive here
if ( cfg.TileSizeXY == 0 )
{
Log.Warning( "buildNavigation: Empty tile provided.\n" );
// Bail nothing more todo
return null;
}
// The geometry may not fully cover the vertical space of the tile.
// By shrinking the bounds we can avoid precissions issues due to large tile height.
// Make sure we are at least couple voxels high though
cfg.Bounds.Maxs.z = inputGeometryMaxZ + cfg.CellHeight * 2; // add alittle bit extra to avoid precission issues
cfg.Bounds.Mins.z = inputGeometryMinZ - cfg.CellHeight * 2; // add alittle bit extra to avoid precission issues
// Set the area where the navigation will be build.
var minBoundsNavSpace = NavMesh.ToNav( cfg.Bounds.Mins );
var maxBoundsNavSpace = NavMesh.ToNav( cfg.Bounds.Maxs );
//
// Step 2. Rasterize input polygon soup.
//
// Allocate voxel heightfield where we rasterize our input data to.
if ( cachedHeightField == null )
{
cachedHeightField = new( cfg.TileSizeXY, cfg.TileSizeXY, minBoundsNavSpace, maxBoundsNavSpace, cfg.CellSize, cfg.CellHeight );
}
else
{
// Reuse memory
cachedHeightField.Init( cfg.TileSizeXY, cfg.TileSizeXY, minBoundsNavSpace, maxBoundsNavSpace, cfg.CellSize, cfg.CellHeight );
}
using var pooledTriAreas = new PooledSpan<int>( ntris );
var triAreas = pooledTriAreas.Span;
InputFilter.MarkWalkableTriangles( cfg.WalkableSlopeAngle, inputGeoVertices.AsSpan( 0, inputGeoVerticesCount ), inputGeoIndices.AsSpan( 0, inputGeoIndicesCount ), triAreas );
Rasterization.RasterizeTriangles( inputGeoVertices.AsSpan( 0, inputGeoVerticesCount ), inputGeoIndices.AsSpan( 0, inputGeoIndicesCount ), triAreas, cachedHeightField, cfg.WalkableClimb );
cachedHeightField.EnsureCompressed();
if ( cachedHeightField.TotalSpanCount == 0 )
{
return null;
}
//
// Step 3. Filter walkables surfaces.
//
// Once all geometry is rasterized, we do initial pass of filtering to
// remove unwanted overhangs caused by the conservative rasterization
// as well as filter spans where the character cannot possibly stand.
SpanFilter.Filter( cfg.WalkableHeight, cfg.WalkableClimb, cachedHeightField );
//
// Step 4. Partition walkable surface to simple regions.
//
// Compact the heightfield so that it is faster to handle from now on.
// This will result more cache coherent data as well as the neighbours
// between walkable cells will be calculated.
#pragma warning disable CA2000 // Dispose objects before losing scope
// chf is returned by this function, caller takes ownership
var chf = cachedHeightField.BuildCompactHeightfield( cfg.WalkableHeight, cfg.WalkableClimb );
#pragma warning restore CA2000 // Dispose objects before losing scope
// Erode the walkable area by agent radius.
AreaFilter.ErodeWalkableArea( cfg.WalkableRadius, chf );
return chf;
}
}