Track/TrackMapBaker.cs

Rasterizes a RacingLine into a top-down RGBA texture for minimap or PNG export. Computes bounds, resamples the spline into evenly spaced points, then rasterizes each segment into a byte[] RGBA buffer with antialiasing, edge/fill colors and simple Z-priority handling.

using System;

namespace Machines.Race;

/// <summary>
/// Result of rasterizing a racing line into a top-down track image.
/// </summary>
public sealed class TrackMapBake
{
	/// <summary>
	/// RGBA8888 pixel data, Resolution * Resolution * 4 bytes.
	/// </summary>
	public byte[] Rgba;

	/// <summary>
	/// Square texture resolution.
	/// </summary>
	public int Resolution;

	/// <summary>
	/// World-XY centre of the square baked region.
	/// </summary>
	public Vector2 Center;

	/// <summary>
	/// World units covered by the square baked region.
	/// </summary>
	public float MaxExtent;

	/// <summary>
	/// World units from centre to the outermost road edge.
	/// </summary>
	public float TrackRadius;
}

/// <summary>
/// Rasterizes a <see cref="RacingLine"/> into a top-down track image for HUD minimap and PNG export.
/// </summary>
public static class TrackMapBaker
{
	private static readonly Color RoadColor = new( 0.11f, 0.11f, 0.15f, 0.5f );
	private static readonly Color OffRoadColor = new( 0.35f, 0.25f, 0.1f, 0.5f );
	private static readonly Color GapColor = new( 0.8f, 0.45f, 0.1f, 0.5f );
	private static readonly Color EdgeColor = new( 1f, 1f, 1f, 1f );

	/// <summary>
	/// Compute the center, extent, and outer road radius of the baked region.
	/// </summary>
	public static (Vector2 Center, float MaxExtent, float TrackRadius) ComputeBounds( RacingLine line, float halfWidth, float outlineWorld )
	{
		// World-XY bounds padded for road width + outline.
		float minX = float.MaxValue, maxX = float.MinValue, minY = float.MaxValue, maxY = float.MinValue;
		foreach ( var p in line.Points )
		{
			minX = MathF.Min( minX, p.x ); maxX = MathF.Max( maxX, p.x );
			minY = MathF.Min( minY, p.y ); maxY = MathF.Max( maxY, p.y );
		}

		var pad = halfWidth + outlineWorld + 40f;
		minX -= pad; maxX += pad; minY -= pad; maxY += pad;

		var center = new Vector2( (minX + maxX) * 0.5f, (minY + maxY) * 0.5f );
		var maxExtent = MathF.Max( maxX - minX, maxY - minY );

		// Radius from map centre to outermost road edge (fits track inside a round display).
		float r2 = 0f;
		foreach ( var p in line.Points )
		{
			var dx = p.x - center.x;
			var dy = p.y - center.y;
			r2 = MathF.Max( r2, dx * dx + dy * dy );
		}
		var trackRadius = MathF.Sqrt( r2 ) + halfWidth + outlineWorld;

		return (center, maxExtent, trackRadius);
	}

	/// <summary>
	/// Bake the track into RGBA pixel data (<paramref name="aaWorld"/> = AA feather in world units, 0 = one texel).
	/// </summary>
	public static TrackMapBake Bake( RacingLine line, float halfWidth, float outlineWorld, int res, float aaWorld = 0f )
	{
		var (center, maxExtent, trackRadius) = ComputeBounds( line, halfWidth, outlineWorld );
		if ( maxExtent < 1f )
			return null;

		var worldPerTexel = maxExtent / res;
		var rOuter = halfWidth + outlineWorld;
		var aaW = MathF.Max( aaWorld, worldPerTexel );

		// Resample centreline into evenly spaced texel-space points.
		const int N = 256;
		var pts = new Vector2[N];
		var ptsZ = new float[N];
		var segIsGap = new bool[N];
		var segIsOffRoad = new bool[N];
		var segLabel = new string[N];
		for ( int i = 0; i < N; i++ )
		{
			var arcDist = (i / (float)N) * line.TotalLength;
			var w = line.GetSplinePointAtDistance( arcDist );
			var nu = 0.5f - (w.y - center.y) / maxExtent;
			var nv = 0.5f - (w.x - center.x) / maxExtent;
			pts[i] = new Vector2( nu * res, nv * res );
			ptsZ[i] = w.z;
			var seg = line.GetSegmentAtDistance( arcDist );
			segIsGap[i] = seg?.IsGap ?? false;
			segIsOffRoad[i] = seg?.Type == SplineType.OffRoad;
			segLabel[i] = seg?.SourceLabel ?? "";
		}

		var data = new byte[res * res * 4];      // zero-init = transparent
		var dist = new float[res * res];         // nearest-segment world distance per texel
		var zLevel = new float[res * res];       // Z of owning segment per texel
		var ownerSeg = new int[res * res];       // owning polyline segment index per texel
		Array.Fill( dist, float.MaxValue );
		Array.Fill( zLevel, float.MinValue );
		Array.Fill( ownerSeg, -1 );

		// Z delta above which a higher segment wins the texel, preventing multi-level merging.
		const float ZOverrideThreshold = 16f;

		var maxR = (rOuter / worldPerTexel) + aaW / worldPerTexel + 2f;

		for ( int i = 0; i < N; i++ )
		{
			var a = pts[i];
			var b = pts[(i + 1) % N];
			var segZ = (ptsZ[i] + ptsZ[(i + 1) % N]) * 0.5f;

			var lo = new Vector2( MathF.Min( a.x, b.x ), MathF.Min( a.y, b.y ) );
			var hi = new Vector2( MathF.Max( a.x, b.x ), MathF.Max( a.y, b.y ) );

			int x0 = Math.Clamp( (int)MathF.Floor( lo.x - maxR ), 0, res - 1 );
			int x1 = Math.Clamp( (int)MathF.Ceiling( hi.x + maxR ), 0, res - 1 );
			int y0 = Math.Clamp( (int)MathF.Floor( lo.y - maxR ), 0, res - 1 );
			int y1 = Math.Clamp( (int)MathF.Ceiling( hi.y + maxR ), 0, res - 1 );

			for ( int ty = y0; ty <= y1; ty++ )
			{
				for ( int tx = x0; tx <= x1; tx++ )
				{
					var dTex = DistPointSegment( tx + 0.5f, ty + 0.5f, a.x, a.y, b.x, b.y );
					var dw = dTex * worldPerTexel;

					var pi = ty * res + tx;

					// Z-priority only between different source splines; same-spline = nearest-distance.
					var owner = ownerSeg[pi];
					var currentLabel = segLabel[i];
					var ownerLabel = owner >= 0 ? segLabel[owner] : "";
					var isDifferentSource = owner >= 0
						&& currentLabel != ownerLabel
						&& currentLabel != "Gap" && ownerLabel != "Gap"
						&& currentLabel.Length > 0 && ownerLabel.Length > 0;

					if ( isDifferentSource )
					{
						var zDiff = segZ - zLevel[pi];
						if ( zDiff < -ZOverrideThreshold )
							continue; // far below - skip
						if ( zDiff > ZOverrideThreshold )
						{
							// Higher - always overwrites
						}
						else if ( dw >= dist[pi] )
						{
							continue; // similar Z, farther in 2D - skip
						}
					}
					else
					{
						// Same source or first write: nearest-distance wins.
						if ( dw >= dist[pi] )
							continue;
					}

					dist[pi] = dw;
					zLevel[pi] = segZ;
					ownerSeg[pi] = i;

					var idx = pi * 4;

					var outerA = 1f - SmoothStep( rOuter - aaW, rOuter + aaW, dw );
					if ( outerA <= 0.003f )
						continue; // outside outline - leave transparent

					var roadT = 1f - SmoothStep( halfWidth - aaW, halfWidth + aaW, dw );
					var fillColor = segIsGap[i] ? GapColor : segIsOffRoad[i] ? OffRoadColor : RoadColor;
					var rgb = Color.Lerp( EdgeColor, fillColor, roadT );
					var alpha = outerA * MathX.Lerp( EdgeColor.a, fillColor.a, roadT );

					var a8 = (byte)Math.Clamp( (int)(alpha * 255f), 0, 255 );

					data[idx + 0] = (byte)Math.Clamp( (int)(rgb.r * 255f), 0, 255 );
					data[idx + 1] = (byte)Math.Clamp( (int)(rgb.g * 255f), 0, 255 );
					data[idx + 2] = (byte)Math.Clamp( (int)(rgb.b * 255f), 0, 255 );
					data[idx + 3] = a8;
				}
			}
		}

		return new TrackMapBake
		{
			Rgba = data,
			Resolution = res,
			Center = center,
			MaxExtent = maxExtent,
			TrackRadius = trackRadius
		};
	}

	private static float DistPointSegment( float px, float py, float ax, float ay, float bx, float by )
	{
		var dx = bx - ax;
		var dy = by - ay;
		var len2 = dx * dx + dy * dy;
		var t = len2 > 1e-6f ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0f;
		t = Math.Clamp( t, 0f, 1f );
		var cx = ax + dx * t;
		var cy = ay + dy * t;
		var ex = px - cx;
		var ey = py - cy;
		return MathF.Sqrt( ex * ex + ey * ey );
	}

	private static float SmoothStep( float edge0, float edge1, float x )
	{
		if ( edge0 == edge1 )
			return x < edge0 ? 0f : 1f;
		var t = Math.Clamp( (x - edge0) / (edge1 - edge0), 0f, 1f );
		return t * t * (3f - 2f * t);
	}
}