Terrain/TileSlope.cs
using System;
using System.Collections;
using System.Collections.Immutable;
namespace HC3.Terrain;
#nullable enable
/// <summary>
/// Describes how a <see cref="TileSlope"/> is triangulated.
/// Some slopes aren't planar, so this decides which diagonal the
/// crease is on.
/// </summary>
public enum TileTriangulation
{
/// <summary>
/// Tile is split between <see cref="TileCorner.XMinYMin"/> and <see cref="TileCorner.XMaxYMax"/>.
/// </summary>
Default,
/// <summary>
/// Tile is split between <see cref="TileCorner.XMaxYMin"/> and <see cref="TileCorner.XMinYMax"/>.
/// </summary>
Flipped
}
/// <summary>
/// Describes the relative heights of each corner of a tile.
/// Heights are relative to the lowest corner, so at least one corner is guaranteed to have a relative height of <c>0</c>.
/// </summary>
public readonly struct TileSlope : IEquatable<TileSlope>, IComparable<TileSlope>
{
internal const int SizeBytes = sizeof( ushort );
private readonly ushort _encoded;
private const int BitsPerCorner = (SizeBytes << 3) >> 2;
private const ushort CornerBitMask = (1 << BitsPerCorner) - 1;
/// <summary>
/// Highest height of a corner in a slope relative to the lowest.
/// </summary>
public const int MaxCornerHeightOffset = CornerBitMask;
/// <summary>
/// Level, flat ground.
/// </summary>
public static TileSlope LevelGround => default;
/// <summary>
/// All legal slopes.
/// </summary>
public static IReadOnlyList<TileSlope> All { get; } = Enumerable.Range( 0, ushort.MaxValue )
.Select( x => new TileSlope( (ushort)x ) )
.Where( x => x.MinHeightOffset == 0 )
.ToImmutableArray();
/// <summary>
/// Will always be 0 for legal slopes, just used for building <see cref="All"/>.
/// </summary>
private int MinHeightOffset => Math.Min(
Math.Min( GetHeightOffset( TileCorner.XMinYMin ), GetHeightOffset( TileCorner.XMaxYMin ) ),
Math.Min( GetHeightOffset( TileCorner.XMinYMax ), GetHeightOffset( TileCorner.XMaxYMax ) ) );
/// <summary>
/// Relative height of the highest corner of this slope.
/// </summary>
public int MaxHeightOffset => Math.Max(
Math.Max( GetHeightOffset( TileCorner.XMinYMin ), GetHeightOffset( TileCorner.XMaxYMin ) ),
Math.Max( GetHeightOffset( TileCorner.XMinYMax ), GetHeightOffset( TileCorner.XMaxYMax ) ) );
/// <summary>
/// True if all corners are at the same height.
/// </summary>
public bool IsLevel => this == LevelGround;
/// <summary>
/// Describes how this slope is triangulated.
/// Some slopes aren't planar, so this decides which diagonal the
/// crease is on.
/// </summary>
public TileTriangulation Triangulation
{
get
{
var diag0 = GetHeightOffset( TileCorner.XMaxYMax ) - GetHeightOffset( TileCorner.XMinYMin );
var diag1 = GetHeightOffset( TileCorner.XMaxYMin ) - GetHeightOffset( TileCorner.XMinYMax );
return Math.Abs( diag0 ) < Math.Abs( diag1 ) ? TileTriangulation.Default : TileTriangulation.Flipped;
}
}
/// <summary>
/// True if opposite edges are parallel.
/// </summary>
public bool IsFlat => GetEdgeGradient( TileEdge.XMin ) == GetEdgeGradient( TileEdge.XMax )
&& GetEdgeGradient( TileEdge.YMin ) == GetEdgeGradient( TileEdge.YMax );
private TileSlope( ushort encoded )
{
_encoded = encoded;
}
/// <summary>
/// Gets the height of a particular corner, relative to the lowest corner.
/// </summary>
public int GetHeightOffset( TileCorner corner )
{
var shift = (int)corner * BitsPerCorner;
return (_encoded & (CornerBitMask << shift)) >> shift;
}
/// <summary>
/// Gets the height offset of a relative position where <c>(0,0)</c> is the height at <see cref="TileCorner.XMinYMin"/>
/// and <c>(1,1)</c> is the height at <see cref="TileCorner.XMaxYMax"/>. Position is clamped on each axis.
/// </summary>
public float GetHeightOffset( Vector2 position )
{
position = Vector2.Max( 0, Vector2.Min( 1, position ) );
Span<int> corners = stackalloc int[4];
GetHeightOffsets( corners );
if ( Triangulation == TileTriangulation.Flipped )
{
// Flip everything horizontally if triangulation is flipped,
// so crease is always from (0,0) to (1,1), in other words x == y
position.x = 1f - position.x;
// Swap heights of corners
(corners[(int)TileCorner.XMinYMin], corners[(int)TileCorner.XMaxYMin])
= (corners[(int)TileCorner.XMaxYMin], corners[(int)TileCorner.XMinYMin]);
(corners[(int)TileCorner.XMinYMax], corners[(int)TileCorner.XMaxYMax])
= (corners[(int)TileCorner.XMaxYMax], corners[(int)TileCorner.XMinYMax]);
}
// Each triangle has its own 2D gradient, we'll find it by getting
// the 1D gradients of the axis aligned edges
Vector2 gradient = default;
// Crease between the two triangles is x == y,
// so one triangle is x > y and the other is x < y
if ( position.x < position.y )
{
gradient.x = corners[(int)TileCorner.XMaxYMax] - corners[(int)TileCorner.XMinYMax];
gradient.y = corners[(int)TileCorner.XMinYMax] - corners[(int)TileCorner.XMinYMin];
}
else
{
gradient.x = corners[(int)TileCorner.XMaxYMin] - corners[(int)TileCorner.XMinYMin];
gradient.y = corners[(int)TileCorner.XMaxYMax] - corners[(int)TileCorner.XMaxYMin];
}
// Height relative to the (0,0) corner, increasing by
// gradient as we travel towards (1,1).
return corners[(int)TileCorner.XMinYMin] + Vector2.Dot( position, gradient );
}
/// <summary>
/// Get the total absolute vertical distances of each corner to the given <paramref name="other"/> slope.
/// </summary>
/// <param name="other">Slope to compare to.</param>
/// <param name="tileHeightOffset">Relative height of the other tile to this one.</param>
public int GetTotalDistance( TileSlope other, int tileHeightOffset = 0 )
{
var sum = 0;
sum += Math.Abs( GetHeightOffset( TileCorner.XMinYMin ) - other.GetHeightOffset( TileCorner.XMinYMin ) - tileHeightOffset );
sum += Math.Abs( GetHeightOffset( TileCorner.XMaxYMin ) - other.GetHeightOffset( TileCorner.XMaxYMin ) - tileHeightOffset );
sum += Math.Abs( GetHeightOffset( TileCorner.XMinYMax ) - other.GetHeightOffset( TileCorner.XMinYMax ) - tileHeightOffset );
sum += Math.Abs( GetHeightOffset( TileCorner.XMaxYMax ) - other.GetHeightOffset( TileCorner.XMaxYMax ) - tileHeightOffset );
return sum;
}
/// <summary>
/// Get the minimum distance when subtracting corner heights of <paramref name="other"/> from this slope's corners.
/// </summary>
public int GetMinDistance( TileSlope other )
{
var min = int.MaxValue;
min = Math.Min( min, GetHeightOffset( TileCorner.XMinYMin ) - other.GetHeightOffset( TileCorner.XMinYMin ) );
min = Math.Min( min, GetHeightOffset( TileCorner.XMaxYMin ) - other.GetHeightOffset( TileCorner.XMaxYMin ) );
min = Math.Min( min, GetHeightOffset( TileCorner.XMinYMax ) - other.GetHeightOffset( TileCorner.XMinYMax ) );
min = Math.Min( min, GetHeightOffset( TileCorner.XMaxYMax ) - other.GetHeightOffset( TileCorner.XMaxYMax ) );
return min;
}
/// <summary>
/// Get the maximum distance when subtracting corner heights of <paramref name="other"/> from this slope's corners.
/// </summary>
public int GetMaxDistance( TileSlope other )
{
var max = int.MinValue;
max = Math.Max( max, GetHeightOffset( TileCorner.XMinYMin ) - other.GetHeightOffset( TileCorner.XMinYMin ) );
max = Math.Max( max, GetHeightOffset( TileCorner.XMaxYMin ) - other.GetHeightOffset( TileCorner.XMaxYMin ) );
max = Math.Max( max, GetHeightOffset( TileCorner.XMinYMax ) - other.GetHeightOffset( TileCorner.XMinYMax ) );
max = Math.Max( max, GetHeightOffset( TileCorner.XMaxYMax ) - other.GetHeightOffset( TileCorner.XMaxYMax ) );
return max;
}
public void GetHeightOffsets( Span<int> heights )
{
heights[(int)TileCorner.XMinYMin] = GetHeightOffset( TileCorner.XMinYMin );
heights[(int)TileCorner.XMaxYMin] = GetHeightOffset( TileCorner.XMaxYMin );
heights[(int)TileCorner.XMinYMax] = GetHeightOffset( TileCorner.XMinYMax );
heights[(int)TileCorner.XMaxYMax] = GetHeightOffset( TileCorner.XMaxYMax );
}
public int GetEdgeGradient( TileEdge edge )
{
var (min, max) = edge.GetCorners();
return GetHeightOffset( max ) - GetHeightOffset( min );
}
public Vector2Int GetGradients()
{
if ( MaxHeightOffset == 0 ) return 0;
var xMin = Math.Max( GetHeightOffset( TileCorner.XMinYMin ), GetHeightOffset( TileCorner.XMinYMax ) );
var xMax = Math.Max( GetHeightOffset( TileCorner.XMaxYMin ), GetHeightOffset( TileCorner.XMaxYMax ) );
var yMin = Math.Max( GetHeightOffset( TileCorner.XMinYMin ), GetHeightOffset( TileCorner.XMaxYMin ) );
var yMax = Math.Max( GetHeightOffset( TileCorner.XMinYMax ), GetHeightOffset( TileCorner.XMaxYMax ) );
return new Vector2Int( xMax - xMin, yMax - yMin );
}
public bool Equals( TileSlope other ) => _encoded == other._encoded;
public override bool Equals( object? obj ) => obj is TileSlope other && Equals( other );
public override int GetHashCode() => _encoded;
public int CompareTo( TileSlope other ) => _encoded.CompareTo( other._encoded );
public override string ToString() =>
$"[{GetHeightOffset( TileCorner.XMinYMin )}, " +
$"{GetHeightOffset( TileCorner.XMaxYMin )}, " +
$"{GetHeightOffset( TileCorner.XMinYMax )}, " +
$"{GetHeightOffset( TileCorner.XMaxYMax )}]";
public static bool operator ==( TileSlope a, TileSlope b ) => a.Equals( b );
public static bool operator !=( TileSlope a, TileSlope b ) => !a.Equals( b );
public static TileSlope FromCornerHeights( ReadOnlySpan<int> heights )
{
ArgumentOutOfRangeException.ThrowIfNotEqual( heights.Length, 4, nameof( heights ) );
ArgumentOutOfRangeException.ThrowIfLessThan( heights[0], 0, nameof( heights ) );
ArgumentOutOfRangeException.ThrowIfLessThan( heights[1], 0, nameof( heights ) );
ArgumentOutOfRangeException.ThrowIfLessThan( heights[2], 0, nameof( heights ) );
ArgumentOutOfRangeException.ThrowIfLessThan( heights[3], 0, nameof( heights ) );
ArgumentOutOfRangeException.ThrowIfGreaterThan( heights[0], MaxCornerHeightOffset, nameof( heights ) );
ArgumentOutOfRangeException.ThrowIfGreaterThan( heights[1], MaxCornerHeightOffset, nameof( heights ) );
ArgumentOutOfRangeException.ThrowIfGreaterThan( heights[2], MaxCornerHeightOffset, nameof( heights ) );
ArgumentOutOfRangeException.ThrowIfGreaterThan( heights[3], MaxCornerHeightOffset, nameof( heights ) );
if ( heights[0] != 0 && heights[1] != 0 && heights[2] != 0 && heights[3] != 0 )
{
throw new ArgumentException( "Expected at least one corner to be at height 0.", nameof( heights ) );
}
return new TileSlope( (ushort)(heights[0] | (heights[1] << BitsPerCorner) | (heights[2] << (BitsPerCorner * 2)) | (heights[3] << (BitsPerCorner * 3))) );
}
}
public sealed class SlopeTileset : IEnumerable<TileSlope>
{
private readonly ImmutableHashSet<TileSlope> _slopes;
private readonly Dictionary<TileSlope, (TileSlope Slope, int Offset)> _below = new();
private readonly Dictionary<TileSlope, (TileSlope Slope, int Offset)> _above = new();
public int Count => _slopes.Count;
public SlopeTileset( IEnumerable<TileSlope> slopes )
{
_slopes = slopes
.Distinct()
.Order()
.ToImmutableHashSet();
}
public bool Contains( TileSlope slope ) => _slopes.Contains( slope );
public TerrainTile FromCornerHeights( ReadOnlySpan<int> cornerHeights, TilePaint paint )
{
ArgumentOutOfRangeException.ThrowIfNotEqual( cornerHeights.Length, 4, nameof( cornerHeights ) );
var min = cornerHeights[0];
var max = cornerHeights[0];
for ( var i = 1; i < 4; ++i )
{
min = Math.Min( min, cornerHeights[i] );
max = Math.Max( max, cornerHeights[i] );
}
min = Math.Clamp( min, 0, ushort.MaxValue );
max = Math.Clamp( max, 0, ushort.MaxValue );
if ( min == max )
{
// Easy common case: no slope
return new TerrainTile( (ushort)min, TileSlope.LevelGround, paint );
}
Span<int> relativeHeights = stackalloc int[4];
for ( var i = 0; i < 4; ++i )
{
relativeHeights[i] = Math.Clamp( cornerHeights[i] - min, 0, TileSlope.MaxCornerHeightOffset );
}
var slope = TileSlope.FromCornerHeights( relativeHeights );
return FindClosestBelow( new TerrainTile( (ushort)min, slope, paint ) );
}
private (TileSlope Slope, int Offset) FindBelowUncached( TileSlope slope )
{
var bestSlope = TileSlope.LevelGround;
var bestOffset = 0;
var bestDistance = int.MaxValue;
foreach ( var other in _slopes )
{
var offset = -other.GetMaxDistance( slope );
var distance = slope.GetTotalDistance( other, offset );
if ( distance < bestDistance )
{
bestSlope = other;
bestOffset = offset;
bestDistance = distance;
}
}
return (bestSlope, bestOffset);
}
private (TileSlope Slope, int Offset) FindAboveUncached( TileSlope slope )
{
var bestSlope = TileSlope.LevelGround;
var bestOffset = 0;
var bestDistance = int.MaxValue;
foreach ( var other in _slopes )
{
var offset = -other.GetMinDistance( slope );
var distance = slope.GetTotalDistance( other, offset );
if ( distance < bestDistance )
{
bestSlope = other;
bestOffset = offset;
bestDistance = distance;
}
}
return (bestSlope, bestOffset);
}
/// <summary>
/// Creates a tile using this tileset that closest matches the given tile, while not going above
/// any of its corner heights.
/// </summary>
public TerrainTile FindClosestBelow( TerrainTile tile )
{
if ( _slopes.Contains( tile.Slope ) ) return tile;
if ( !_below.TryGetValue( tile.Slope, out var below ) )
{
_below[tile.Slope] = below = FindBelowUncached( tile.Slope );
}
return tile.MinHeight + below.Offset < 0
? TerrainTile.LevelGround( 0 )
: new TerrainTile( (ushort)(tile.MinHeight + below.Offset), below.Slope, tile.Paint );
}
/// <summary>
/// Creates a tile using this tileset that closest matches the given tile, while not going below
/// any of its corner heights.
/// </summary>
public TerrainTile FindClosestAbove( TerrainTile tile )
{
if ( _slopes.Contains( tile.Slope ) ) return tile;
if ( !_above.TryGetValue( tile.Slope, out var above ) )
{
_above[tile.Slope] = above = FindAboveUncached( tile.Slope );
}
return new TerrainTile( (ushort)(tile.MinHeight + above.Offset), above.Slope, tile.Paint );
}
public static SlopeTileset FromMaxGradients( IEnumerable<TileSlope> slopes, int maxGradientX, int maxGradientY )
{
return new SlopeTileset( slopes
.Where( slope =>
{
if ( !slope.IsFlat ) return false;
if ( Math.Abs( slope.GetEdgeGradient( TileEdge.YMin ) ) > maxGradientX ) return false;
if ( Math.Abs( slope.GetEdgeGradient( TileEdge.XMin ) ) > maxGradientY ) return false;
return true;
} ) );
}
public static SlopeTileset FromMaxEdgeGradient( IEnumerable<TileSlope> slopes, int maxGradient )
{
return new SlopeTileset( slopes
.Where( slope =>
{
Span<int> c = stackalloc int[4];
slope.GetHeightOffsets( c );
if ( Math.Abs( c[1] - c[0] ) > maxGradient ) return false;
if ( Math.Abs( c[2] - c[0] ) > maxGradient ) return false;
if ( Math.Abs( c[3] - c[1] ) > maxGradient ) return false;
if ( Math.Abs( c[3] - c[2] ) > maxGradient ) return false;
return true;
} ) );
}
public IEnumerator<TileSlope> GetEnumerator() => _slopes.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}