Rides/TrackRides/TrackMesh.cs
using HC3.Terrain;
using System;
using System.Collections.Immutable;
namespace HC3.Rides;
#nullable enable
public sealed class TrackMesh : Component, Component.ExecuteInEditor, ITrackEvent
{
[RequireComponent] public TrackSection Track { get; set; } = null!;
[RequireComponent] public ModelRenderer Renderer { get; set; } = null!;
[field: ThreadStatic]
private static TrackMeshWriter? Writer { get; set; }
private Model? _model;
private Mesh? _mesh;
void ITrackEvent.Changed( TrackSection changedTrack )
{
var thisTrack = Track;
// We want to update the mesh for changes to this track, or overlapping tracks
if ( changedTrack != thisTrack && !changedTrack.WorldBounds.Overlaps( thisTrack.WorldBounds ) ) return;
// Might be getting removed
if ( !thisTrack.IsValid ) return;
// IMPORTANT: need to find overlapping track BEFORE accessing Writer, to avoid it being reused on the same thread
// because accessing ITrackSection.WorldBounds can lead to a ITrackEvent.Changed call.
var overlaps = Scene.GetAllComponents<TrackSection>()
.Where( x => x != thisTrack )
.Where( x => x.WorldBounds.Overlaps( thisTrack.WorldBounds ) )
.Select( x => new OverlappingTrack( x, thisTrack.WorldTransform.ToLocal( x.WorldTransform ) ) )
.ToImmutableArray();
var writer = Writer ??= new TrackMeshWriter();
writer.Clear();
// TODO: support multiple materials
var material = thisTrack.TrackDefinition?.Rails?.Materials.FirstOrDefault();
var grid = GridManager.Instance;
var terrain = grid?.Terrain ?? Scene.Get<ParkTerrain>();
if ( thisTrack.TrackDefinition is { } trackDef )
{
writer.Write( thisTrack, WorldTransform, trackDef, grid, terrain, overlaps );
}
if ( material is null || writer.IsEmpty )
{
Renderer.Enabled = false;
return;
}
if ( !_mesh.IsValid() )
{
_mesh = new Mesh( material );
}
var bounds = writer.CopyTo( _mesh );
if ( !_model.IsValid() )
{
_model = new ModelBuilder()
.AddMesh( _mesh )
.Create();
}
Renderer.Model = _model;
Renderer.Enabled = true;
if ( Renderer.SceneObject.IsValid() )
{
// Workaround for SceneObject.LocalBounds not handling rotations for some reason
Renderer.SceneObject.LocalBounds = bounds.Transform( new Transform( default, WorldTransform.Rotation ) );
}
}
}
/// <summary>
/// A potentially overlapping section of track, used by <see cref="TrackMeshWriter"/> when generating supports.
/// </summary>
/// <param name="Track">Overlapping track.</param>
/// <param name="Transform">Transform from the track being meshed to the overlapping track.</param>
public readonly record struct OverlappingTrack( ITrackSection Track, Transform Transform );
public sealed class TrackMeshWriter : MeshWriter<Vertex>
{
public void Write( ITrackSection track, Transform worldTransform, TrackDefinition trackDef, GridManager? grid = null, ParkTerrain? terrain = null, IReadOnlyList<OverlappingTrack>? overlaps = null )
{
if ( track.Nodes.Count < 2 ) return;
var prevDist = 0f;
var prevNodeTransform = track.SampleAtNode( 0 );
Bounds = Bounds.AddBBox( new BBox( prevNodeTransform.Position - 32f, prevNodeTransform.Position + 32f ) );
var sinceSupport = 0;
var selfOverlap = new OverlappingTrack( track, Transform.Zero );
overlaps = overlaps?.Union( [selfOverlap] ).ToArray() ?? new[] { selfOverlap };
var nodes = new HashSet<TrackNode>();
for ( var i = 0; i < track.Elements.Count; ++i )
{
var element = track.Elements[i];
var nextNodeTransform = track.SampleAtNode( i + 1 );
var nextDist = track.GetDistanceAtNode( i + 1 );
nodes.Clear();
nodes.Add( track.Nodes[i + 0] );
nodes.Add( track.Nodes[i + 1] );
var elementLength = element.Length;
// TODO: stitch rail segments together?
var steps = !IsTrivial( prevNodeTransform, nextNodeTransform, elementLength )
? (int)MathF.Round( elementLength * 4 / GridManager.GridSize )
: 2;
var prevStepTransform = prevNodeTransform;
var feature = element.Definition.Feature;
for ( var j = 1; j <= steps; ++j )
{
var t = (float)j / steps;
var nextStepTransform = track.SampleAtDistance( prevDist + t * elementLength );
trackDef.WriteRail( this, prevStepTransform, nextStepTransform, feature );
prevStepTransform = nextStepTransform;
Bounds = Bounds.AddBBox( new BBox( nextStepTransform.Position - 32f, nextStepTransform.Position + 32f ) );
}
var strutCount = (int)MathF.Round( elementLength * trackDef.StrutFrequency / GridManager.GridSize );
for ( var j = 0; j < strutCount; ++j )
{
var t = (j + 0.5f) / strutCount;
var transform = track.SampleAtDistance( prevDist + t * elementLength );
trackDef.WriteStrut( this, transform );
}
var parameters = new SupportParameters( track, worldTransform, trackDef, grid, terrain, prevDist, element, nodes, overlaps );
switch ( trackDef.SupportStyle )
{
case SupportStyle.Column:
WriteColumnSupports( parameters, ref sinceSupport );
break;
case SupportStyle.Scaffold:
WriteScaffoldSupports( parameters );
break;
}
prevDist = nextDist;
prevNodeTransform = nextNodeTransform;
}
}
private readonly record struct SupportParameters(
ITrackSection Track,
Transform WorldTransform,
TrackDefinition TrackDef,
GridManager? Grid,
ParkTerrain? Terrain,
float PrevDist,
TrackElement Element,
IReadOnlySet<TrackNode> Nodes,
IReadOnlyList<OverlappingTrack> Overlaps );
private void WriteColumnSupports( in SupportParameters parameters, ref int sinceSupport )
{
var (track, worldTransform, trackDef, grid, terrain, prevDist, element, nodes, overlaps) = parameters;
var elementLength2d = element.Length2D;
var tileCount = (int)MathF.Round( elementLength2d / GridManager.GridSize );
for ( var i = 0; i < tileCount; ++i )
{
var t = (i + 0.5f) / tileCount;
var transform = track.SampleAtDistance( prevDist + t * elementLength2d );
++sinceSupport;
if ( sinceSupport < trackDef.SupportSpacing )
{
continue;
}
if ( transform.Up.z <= 0f )
{
// Track is too tilted for a support
continue;
}
var baseZ = 0f;
var connectionOffset = MathX.Lerp( MathX.Lerp( 8f, 32f, transform.Position.z / 32f ), 8f, transform.Up.z );
var connectionTop = transform.Position + transform.Down * connectionOffset;
if ( terrain is not null )
{
var gridPos = (Vector2)terrain.WorldToGrid( worldTransform.PointToWorld( connectionTop ) );
var gridHeight = terrain.GetHeight( gridPos ) * GridManager.HeightStep;
baseZ = gridHeight - worldTransform.Position.z;
if ( baseZ >= transform.Position.z - 16f )
{
// On the ground, no need for a support
sinceSupport = trackDef.SupportSpacing / 2;
continue;
}
}
// Check grid manager for obstructions
if ( GetMinUnobstructedHeight( ref baseZ, connectionTop, worldTransform, grid ) ) continue;
// Test other tracks for obstructions
if ( GetMinUnobstructedHeight( ref baseZ, overlaps, connectionTop, nodes ) ) continue;
var canPlaceSupport = true;
if ( !canPlaceSupport ) continue;
sinceSupport = 0;
trackDef.WriteSupportColumn( this, transform, connectionOffset, baseZ );
}
}
private void WriteScaffoldSupports( in SupportParameters parameters )
{
var (track, worldTransform, trackDef, grid, terrain, prevDist, element, nodes, overlaps) = parameters;
var prevTransform = track.SampleAtDistance( prevDist );
foreach ( var t in element.TileBoundaries.Skip( 1 ) )
{
var nextTransform = track.SampleAtDistance( prevDist + t );
if ( prevTransform.Up.z <= 0f || nextTransform.Up.z <= 0f )
{
prevTransform = nextTransform;
// Track is too tilted for a support
continue;
}
var minZ = 0f;
var center = Vector3.Lerp( prevTransform.Position, nextTransform.Position, 0.5f );
if ( terrain is not null )
{
var gridPos = (Vector2)terrain.WorldToGrid( worldTransform.PointToWorld( center ) );
var gridHeight = terrain[terrain.GetTileIndex( gridPos )].MinHeight * GridManager.HeightStep;
minZ = gridHeight - worldTransform.Position.z - TrackDefinition.UnitSupportHeight;
}
var baseZ = minZ;
// Check grid manager for obstructions
// Sample 10% and 90% of the way into the tile so we have two chances at hitting something,
// while avoiding testing outside the tile because of float imprecision
var a = Vector3.Lerp( prevTransform.Position, nextTransform.Position, 0.1f );
var b = Vector3.Lerp( prevTransform.Position, nextTransform.Position, 0.9f );
// Check grid manager for obstructions
GetMinUnobstructedHeight( ref baseZ, a, worldTransform, grid );
GetMinUnobstructedHeight( ref baseZ, b, worldTransform, grid );
// Test other tracks for obstructions
GetMinUnobstructedHeight( ref baseZ, overlaps, a, nodes );
GetMinUnobstructedHeight( ref baseZ, overlaps, b, nodes );
var maxBaseZ = Math.Min( prevTransform.Position.z, nextTransform.Position.z );
if ( baseZ >= maxBaseZ )
{
prevTransform = nextTransform;
continue;
}
if ( baseZ > minZ )
{
baseZ = Math.Max( baseZ, maxBaseZ - TrackDefinition.UnitSupportHeight );
}
trackDef.WriteSupportScaffold( this, prevTransform, nextTransform, baseZ );
prevTransform = nextTransform;
}
}
/// <summary>
/// Test for <see cref="GridObject"/>s that could obstruct support placement.
/// </summary>
private bool GetMinUnobstructedHeight( ref float baseZ, Vector3 pos, Transform worldTransform, GridManager? grid )
{
var worldPos = worldTransform.PointToWorld( pos );
if ( grid?.GetCell( GridManager.WorldToGridPosition( worldPos ) ) is not { } cell ) return false;
var oldBaseZ = baseZ;
foreach ( var obj in cell )
{
if ( !obj.BlocksConstruction ) continue;
if ( obj.WorldPosition.z > worldPos.z ) continue;
baseZ = Math.Max( baseZ, obj.WorldPosition.z + obj.Height * GridManager.HeightStep - worldTransform.Position.z );
}
return baseZ > oldBaseZ;
}
/// <summary>
/// Test for <see cref="OverlappingTrack"/>s that could obstruct support placement.
/// </summary>
private bool GetMinUnobstructedHeight( ref float baseZ, IReadOnlyList<OverlappingTrack> overlaps, Vector3 pos, IReadOnlySet<TrackNode>? nodes )
{
for ( var z = pos.z; z >= baseZ; z -= TrackSection.GridSize )
{
foreach ( var overlap in overlaps )
{
var localPos = (overlap.Transform.PointToLocal( pos.WithZ( z ) ) / TrackSection.GridSize).FloorToInt();
var ignoreNodes = overlap.Transform == Transform.Zero ? nodes : null;
if ( !overlap.Track.HasObstruction( localPos, ignoreNodes ) ) continue;
baseZ = z + TrackSection.GridSize;
return true;
}
}
return false;
}
/// <summary>
/// Can we just use a straight track segment between these points?
/// </summary>
private static bool IsTrivial( in Transform prev, in Transform next, float length ) =>
prev.Rotation.AlmostEqual( next.Rotation ) &&
(next.Position - prev.Position).Length - length < 1f &&
prev.Forward.Dot( (next.Position - prev.Position).Normal ) > 0.999f;
}