Generators/ChannelCombineTextureGenerator.cs
using System.Text.Json.Serialization;
using System.Threading;

namespace Sandbox.Resources;

/// <summary>
/// Which channel to read from the source image.
/// </summary>
public enum ChannelSource
{
	Red,
	Green,
	Blue,
	Alpha,
	Luminance
}

/// <summary>
/// Combines separate image files into a single texture by assigning each to an output R, G, B, or A channel.
/// </summary>
[Order( -90 )]
[Title( "RGBA Channels" )]
[Icon( "tune" )]
[ClassName( "channelcombine" )]
public class ChannelCombineGenerator : TextureGenerator
{
	/// <summary>
	/// The image file used for the Red output channel.
	/// </summary>
	[Header( "Red Channel" )]
	[Title( "File Path" )]
	[TextureImagePath]
	public string RedFilePath { get; set; }

	/// <summary>
	/// Which channel to read from the red source image.
	/// </summary>
	[Title( "Source" )]
	public ChannelSource RedSource { get; set; } = ChannelSource.Red;

	/// <summary>
	/// The default value for the Red channel when no image is provided (0-1).
	/// </summary>
	[Title( "Default" )]
	[Range( 0, 1 )]
	public float RedDefault { get; set; } = 0.0f;

	/// <summary>
	/// The image file used for the Green output channel.
	/// </summary>
	[Header( "Green Channel" )]
	[Title( "File Path" )]
	[TextureImagePath]
	public string GreenFilePath { get; set; }

	/// <summary>
	/// Which channel to read from the green source image.
	/// </summary>
	[Title( "Source" )]
	public ChannelSource GreenSource { get; set; } = ChannelSource.Green;

	/// <summary>
	/// The default value for the Green channel when no image is provided (0-1).
	/// </summary>
	[Title( "Default" )]
	[Range( 0, 1 )]
	public float GreenDefault { get; set; } = 0.0f;

	/// <summary>
	/// The image file used for the Blue output channel.
	/// </summary>
	[Header( "Blue Channel" )]
	[Title( "File Path" )]
	[TextureImagePath]
	public string BlueFilePath { get; set; }

	/// <summary>
	/// Which channel to read from the blue source image.
	/// </summary>
	[Title( "Source" )]
	public ChannelSource BlueSource { get; set; } = ChannelSource.Blue;

	/// <summary>
	/// The default value for the Blue channel when no image is provided (0-1).
	/// </summary>
	[Title( "Default" )]
	[Range( 0, 1 )]
	public float BlueDefault { get; set; } = 0.0f;

	/// <summary>
	/// The image file used for the Alpha output channel.
	/// </summary>
	[Header( "Alpha Channel" )]
	[Title( "File Path" )]
	[TextureImagePath]
	public string AlphaFilePath { get; set; }

	/// <summary>
	/// Which channel to read from the alpha source image.
	/// </summary>
	[Title( "Source" )]
	public ChannelSource AlphaSource { get; set; } = ChannelSource.Luminance;

	/// <summary>
	/// The default value for the Alpha channel when no image is provided (0-1).
	/// </summary>
	[Title( "Default" )]
	[Range( 0, 1 )]
	public float AlphaDefault { get; set; } = 1.0f;

	/// <summary>
	/// The maximum output size in pixels. Source images are resized to a common resolution before combining.
	/// </summary>
	[Header( "Output" )]
	public int MaxSize { get; set; } = 2048;

	[Hide]
	public override bool CacheToDisk => true;

	protected override async ValueTask<Texture> CreateTexture( Options options, CancellationToken ct )
	{
		var redBitmap = await LoadBitmap( RedFilePath );
		var greenBitmap = await LoadBitmap( GreenFilePath );
		var blueBitmap = await LoadBitmap( BlueFilePath );
		var alphaBitmap = await LoadBitmap( AlphaFilePath );

		try
		{
			// Register compile references
			if ( options.Compiler is not null )
			{
				if ( !string.IsNullOrWhiteSpace( RedFilePath ) ) options.Compiler.Context.AddCompileReference( RedFilePath );
				if ( !string.IsNullOrWhiteSpace( GreenFilePath ) ) options.Compiler.Context.AddCompileReference( GreenFilePath );
				if ( !string.IsNullOrWhiteSpace( BlueFilePath ) ) options.Compiler.Context.AddCompileReference( BlueFilePath );
				if ( !string.IsNullOrWhiteSpace( AlphaFilePath ) ) options.Compiler.Context.AddCompileReference( AlphaFilePath );
			}

			// Determine output size from the largest source image
			int width = 1;
			int height = 1;

			foreach ( var bmp in (Bitmap[])[redBitmap, greenBitmap, blueBitmap, alphaBitmap] )
			{
				if ( bmp is null ) continue;
				width = Math.Max( width, bmp.Width );
				height = Math.Max( height, bmp.Height );
			}

			// Clamp to max size
			if ( width > MaxSize || height > MaxSize )
			{
				float scale = Math.Min( (float)MaxSize / width, (float)MaxSize / height );
				width = Math.Max( 1, (int)(width * scale) );
				height = Math.Max( 1, (int)(height * scale) );
			}

			// Resize all source bitmaps to common resolution
			redBitmap = ResizeIfNeeded( redBitmap, width, height );
			greenBitmap = ResizeIfNeeded( greenBitmap, width, height );
			blueBitmap = ResizeIfNeeded( blueBitmap, width, height );
			alphaBitmap = ResizeIfNeeded( alphaBitmap, width, height );

			// Read pixel arrays
			var redPixels = redBitmap?.GetPixels();
			var greenPixels = greenBitmap?.GetPixels();
			var bluePixels = blueBitmap?.GetPixels();
			var alphaPixels = alphaBitmap?.GetPixels();

			// Combine into output
			using var output = new Bitmap( width, height );
			var outputPixels = new Color[width * height];

			for ( int i = 0; i < outputPixels.Length; i++ )
			{
				float r = SampleChannel( redPixels, i, RedSource, RedDefault );
				float g = SampleChannel( greenPixels, i, GreenSource, GreenDefault );
				float b = SampleChannel( bluePixels, i, BlueSource, BlueDefault );
				float a = SampleChannel( alphaPixels, i, AlphaSource, AlphaDefault );

				outputPixels[i] = new Color( r, g, b, a );
			}

			output.SetPixels( outputPixels );
			return output.ToTexture();
		}
		finally
		{
			redBitmap?.Dispose();
			greenBitmap?.Dispose();
			blueBitmap?.Dispose();
			alphaBitmap?.Dispose();
		}
	}

	private static float SampleChannel( Color[] pixels, int index, ChannelSource source, float defaultValue )
	{
		if ( pixels is null ) return defaultValue;

		var c = pixels[index];
		return source switch
		{
			ChannelSource.Red => c.r,
			ChannelSource.Green => c.g,
			ChannelSource.Blue => c.b,
			ChannelSource.Alpha => c.a,
			ChannelSource.Luminance => c.r * 0.2126f + c.g * 0.7152f + c.b * 0.0722f,
			_ => defaultValue
		};
	}

	private static Bitmap ResizeIfNeeded( Bitmap bitmap, int width, int height )
	{
		if ( bitmap is null ) return null;
		if ( bitmap.Width == width && bitmap.Height == height ) return bitmap;

		var resized = bitmap.Resize( width, height );
		bitmap.Dispose();
		return resized;
	}

	private static async Task<Bitmap> LoadBitmap( string filePath )
	{
		if ( string.IsNullOrWhiteSpace( filePath ) ) return null;

		var path = filePath.NormalizeFilename();
		if ( !FileSystem.Mounted.FileExists( path ) )
		{
			Log.Warning( $"ChannelCombineGenerator could not find file: {path}" );
			return null;
		}

		var bytes = await FileSystem.Mounted.ReadAllBytesAsync( path );
		return Bitmap.CreateFromBytes( bytes );
	}
}