Editor/PbrGenerator.cs
using System;
using Sandbox;

internal sealed class PbrGeneratorPixelResult
{
	public int Width { get; set; }
	public int Height { get; set; }
	public bool AlbedoIsFloatingPoint { get; set; }
	public Color[] AlbedoPixels { get; set; }
	public Color[] HeightPixels { get; set; }
	public Color[] NormalPixels { get; set; }
	public Color[] RoughnessPixels { get; set; }
	public Color[] AmbientOcclusionPixels { get; set; }
	public Color[] MetallicPixels { get; set; }
	public Color[] OrmPixels { get; set; }
}

public static class PbrGenerator
{
	public static PbrGeneratorResult Generate( Bitmap source, PbrGeneratorSettings settings )
	{
		if ( source == null || !source.IsValid )
			return null;

		settings ??= new PbrGeneratorSettings();

		var width = source.Width;
		var height = source.Height;

		if ( width <= 0 || height <= 0 )
			return null;

		var sourcePixels = source.GetPixels();

		var pixels = GeneratePixels( sourcePixels, width, height, source.IsFloatingPoint, settings );
		return CreateBitmapResult( pixels );
	}

	internal static PbrGeneratorPixelResult GeneratePixels( Color[] sourcePixels, int width, int height, bool albedoIsFloatingPoint, PbrGeneratorSettings settings )
	{
		if ( sourcePixels == null || width <= 0 || height <= 0 || sourcePixels.Length < width * height )
			return null;

		settings ??= new PbrGeneratorSettings();

		var gray = CreateLuminanceValues( sourcePixels );
		var heightValues = CreateHeightValues( gray, width, height, settings );
		var normalValues = CreateNormalSourceValues( heightValues, width, height, settings );
		var roughnessValues = CreateRoughnessValues( gray, heightValues, width, height, settings );
		var aoValues = CreateAoValues( heightValues, width, height, settings );
		var metallicValues = CreateFlatValues( width * height, settings.MetallicValue );

		return new PbrGeneratorPixelResult
		{
			Width = width,
			Height = height,
			AlbedoIsFloatingPoint = albedoIsFloatingPoint,
			AlbedoPixels = (Color[])sourcePixels.Clone(),
			HeightPixels = CreateGrayscalePixels( heightValues ),
			NormalPixels = CreateNormalPixels( normalValues, width, height, settings ),
			RoughnessPixels = CreateGrayscalePixels( roughnessValues ),
			AmbientOcclusionPixels = CreateGrayscalePixels( aoValues ),
			MetallicPixels = CreateGrayscalePixels( metallicValues ),
			OrmPixels = CreateOrmPixels( aoValues, roughnessValues, metallicValues )
		};
	}

	internal static PbrGeneratorResult CreateBitmapResult( PbrGeneratorPixelResult pixels )
	{
		if ( pixels == null || pixels.Width <= 0 || pixels.Height <= 0 )
			return null;

		return new PbrGeneratorResult
		{
			Albedo = CreateBitmap( pixels.AlbedoPixels, pixels.Width, pixels.Height, pixels.AlbedoIsFloatingPoint ),
			Height = CreateBitmap( pixels.HeightPixels, pixels.Width, pixels.Height, false ),
			Normal = CreateBitmap( pixels.NormalPixels, pixels.Width, pixels.Height, false ),
			Roughness = CreateBitmap( pixels.RoughnessPixels, pixels.Width, pixels.Height, false ),
			AmbientOcclusion = CreateBitmap( pixels.AmbientOcclusionPixels, pixels.Width, pixels.Height, false ),
			Metallic = CreateBitmap( pixels.MetallicPixels, pixels.Width, pixels.Height, false ),
			Orm = CreateBitmap( pixels.OrmPixels, pixels.Width, pixels.Height, false )
		};
	}

	public static string GetMapLabel( PbrMapType mapType )
	{
		return mapType switch
		{
			PbrMapType.Albedo => "Albedo",
			PbrMapType.Height => "Height",
			PbrMapType.Normal => "Normal",
			PbrMapType.Roughness => "Roughness",
			PbrMapType.AmbientOcclusion => "AO",
			PbrMapType.Metallic => "Metallic",
			PbrMapType.Orm => "ORM",
			_ => "Map"
		};
	}

	public static string GetMapSuffix( PbrMapType mapType )
	{
		return mapType switch
		{
			PbrMapType.Albedo => "_albedo",
			PbrMapType.Height => "_height",
			PbrMapType.Normal => "_normal",
			PbrMapType.Roughness => "_roughness",
			PbrMapType.AmbientOcclusion => "_ao",
			PbrMapType.Metallic => "_metallic",
			PbrMapType.Orm => "_orm",
			_ => "_map"
		};
	}

	private static float[] CreateLuminanceValues( Color[] sourcePixels )
	{
		var values = new float[sourcePixels.Length];

		for ( var i = 0; i < sourcePixels.Length; i++ )
		{
			values[i] = SeamlessSuiteImageUtility.GetLuminance( sourcePixels[i] );
		}

		return values;
	}

	private static float[] CreateHeightValues( float[] gray, int width, int height, PbrGeneratorSettings settings )
	{
		if ( settings.AdvancedHeightEnabled )
			return CreateAdvancedHeightValues( gray, width, height, settings );

		return CreateSimpleHeightValues( gray, width, height, settings );
	}

	private static float[] CreateSimpleHeightValues( float[] gray, int width, int height, PbrGeneratorSettings settings )
	{
		var values = new float[gray.Length];
		var contrast = MathF.Max( 0f, settings.HeightContrast );
		var strength = MathF.Max( 0f, settings.HeightStrength );
		var blackPoint = SeamlessSuiteImageUtility.Clamp01( settings.HeightBlackPoint );
		var whitePoint = SeamlessSuiteImageUtility.Clamp01( settings.HeightWhitePoint );
		var midpointOffset = 0.5f - SeamlessSuiteImageUtility.Clamp01( settings.HeightMidpoint );
		var cavityEmphasis = SeamlessSuiteImageUtility.Clamp01( settings.HeightCavityEmphasis );

		if ( whitePoint <= blackPoint + 0.001f )
		{
			whitePoint = Math.Min( 1f, blackPoint + 0.001f );

			if ( whitePoint <= blackPoint )
				blackPoint = Math.Max( 0f, whitePoint - 0.001f );
		}

		for ( var i = 0; i < gray.Length; i++ )
		{
			var value = (gray[i] - blackPoint) / (whitePoint - blackPoint);
			value = SeamlessSuiteImageUtility.Clamp01( value );
			value = 0.5f + (value - 0.5f) * contrast;
			value = 0.5f + (value - 0.5f) * strength;
			value += midpointOffset;
			value = ApplyHeightCavityEmphasis( value, cavityEmphasis );
			values[i] = SeamlessSuiteImageUtility.Clamp01( value );
		}

		if ( settings.HeightBlur > 0.01f )
			values = FastBlurValues( values, width, height, settings.HeightBlur );

		if ( settings.InvertHeight )
		{
			for ( var i = 0; i < values.Length; i++ )
			{
				values[i] = 1f - values[i];
			}
		}

		return values;
	}

	private static float[] CreateAdvancedHeightValues( float[] gray, int width, int height, PbrGeneratorSettings settings )
	{
		var scale = MathF.Max( 1f, settings.AdvancedHeightScaleRadius );
		var fineRadius = Math.Max( 1, (int)MathF.Round( scale ) );
		var mediumRadius = Math.Max( fineRadius + 1, (int)MathF.Round( scale * 3f ) );
		var largeRadius = Math.Max( mediumRadius + 1, (int)MathF.Round( scale * 8f ) );
		var broadRadius = Math.Max( largeRadius + 1, (int)MathF.Round( scale * 16f ) );

		var fineBlur = FastBlurValues( gray, width, height, fineRadius );
		var mediumBlur = FastBlurValues( gray, width, height, mediumRadius );
		var largeBlur = FastBlurValues( gray, width, height, largeRadius );
		var broadBlur = FastBlurValues( gray, width, height, broadRadius );

		var fineStrength = MathF.Max( 0f, settings.AdvancedHeightFineStrength );
		var mediumStrength = MathF.Max( 0f, settings.AdvancedHeightMediumStrength );
		var largeStrength = MathF.Max( 0f, settings.AdvancedHeightLargeStrength );
		var broadStrength = MathF.Max( 0f, settings.AdvancedHeightBroadStrength );
		var finalContrast = MathF.Max( 0f, settings.AdvancedHeightFinalContrast );
		var gain = MathF.Max( 0f, settings.AdvancedHeightGain );
		var bias = settings.AdvancedHeightBias;
		var values = new float[gray.Length];

		for ( var i = 0; i < gray.Length; i++ )
		{
			var baseHeight = gray[i] - 0.5f;
			var fineDetail = gray[i] - fineBlur[i];
			var mediumDetail = fineBlur[i] - mediumBlur[i];
			var largeDetail = mediumBlur[i] - largeBlur[i];
			var broadDetail = largeBlur[i] - broadBlur[i];

			var value = 0.5f + baseHeight
				+ fineDetail * fineStrength
				+ mediumDetail * mediumStrength
				+ largeDetail * largeStrength
				+ broadDetail * broadStrength;

			value = 0.5f + (value - 0.5f) * gain;
			value = 0.5f + (value - 0.5f) * finalContrast;
			value += bias;
			values[i] = SeamlessSuiteImageUtility.Clamp01( value );
		}

		if ( settings.AdvancedHeightSmoothing > 0.01f )
			values = FastBlurValues( values, width, height, settings.AdvancedHeightSmoothing );

		if ( settings.InvertHeight )
		{
			for ( var i = 0; i < values.Length; i++ )
			{
				values[i] = 1f - values[i];
			}
		}

		return values;
	}

	private static float ApplyHeightCavityEmphasis( float value, float amount )
	{
		if ( amount <= 0f || value >= 0.5f )
			return value;

		value = SeamlessSuiteImageUtility.Clamp01( value );
		var recessed = value / 0.5f;
		var emphasized = recessed * recessed * 0.5f;
		return value + (emphasized - value) * amount;
	}

	private static float[] CreateNormalSourceValues( float[] heightValues, int width, int height, PbrGeneratorSettings settings )
	{
		if ( settings.NormalSmoothing <= 0.01f )
			return heightValues;

		return FastBlurValues( heightValues, width, height, settings.NormalSmoothing );
	}

	private static float[] CreateRoughnessValues( float[] gray, float[] heightValues, int width, int height, PbrGeneratorSettings settings )
	{
		var values = new float[gray.Length];
		var baseValue = SeamlessSuiteImageUtility.Clamp01( settings.RoughnessBase );
		var contrast = SeamlessSuiteImageUtility.Clamp01( settings.RoughnessContrast );
		var blackPoint = SeamlessSuiteImageUtility.Clamp01( settings.RoughnessBlackPoint );
		var whitePoint = SeamlessSuiteImageUtility.Clamp01( settings.RoughnessWhitePoint );
		var cavity = SeamlessSuiteImageUtility.Clamp01( settings.RoughnessCavity );
		var variation = SeamlessSuiteImageUtility.Clamp01( settings.RoughnessVariation );
		var blurred = BoxBlurValues( gray, width, height, 2 );

		if ( whitePoint <= blackPoint + 0.001f )
		{
			whitePoint = Math.Min( 1f, blackPoint + 0.001f );

			if ( whitePoint <= blackPoint )
				blackPoint = Math.Max( 0f, whitePoint - 0.001f );
		}

		for ( var y = 0; y < height; y++ )
		{
			for ( var x = 0; x < width; x++ )
			{
				var index = y * width + x;
				var detail = SeamlessSuiteImageUtility.Clamp01( MathF.Abs( gray[index] - blurred[index] ) * 2f );
				var value = baseValue + (detail - 0.5f) * contrast;

				if ( cavity > 0f )
				{
					var recessed = SeamlessSuiteImageUtility.Clamp01( (0.5f - heightValues[index]) * 2f );
					value = SeamlessSuiteImageUtility.Lerp( value, 1f, recessed * recessed * cavity );
				}

				if ( variation > 0f )
				{
					var noise = GetWrappedValueNoise( x, y, width, height );
					value += (noise - 0.5f) * 0.25f * variation;
				}

				value = SeamlessSuiteImageUtility.Clamp01( value );
				value = SeamlessSuiteImageUtility.Clamp01( (value - blackPoint) / (whitePoint - blackPoint) );

				if ( settings.InvertRoughness )
					value = 1f - value;

				values[index] = value;
			}
		}

		return values;
	}

	private static float[] CreateAoValues( float[] heightValues, int width, int height, PbrGeneratorSettings settings )
	{
		var values = new float[heightValues.Length];
		var strength = Math.Clamp( settings.AoStrength, 0f, 2f );
		var radius = Math.Clamp( (int)MathF.Round( settings.AoRadius ), 1, Math.Max( width, height ) );
		var minimum = SeamlessSuiteImageUtility.Clamp01( settings.AoMinimum );
		var sampleOffsets = new[]
		{
			( radius, 0 ),
			( -radius, 0 ),
			( 0, radius ),
			( 0, -radius ),
			( radius, radius ),
			( -radius, radius ),
			( radius, -radius ),
			( -radius, -radius )
		};

		for ( var y = 0; y < height; y++ )
		{
			for ( var x = 0; x < width; x++ )
			{
				var index = y * width + x;
				var center = heightValues[index];
				var cavity = 0f;

				foreach ( var offset in sampleOffsets )
				{
					var sample = SampleWrapped( heightValues, width, height, x + offset.Item1, y + offset.Item2 );
					cavity += MathF.Max( 0f, sample - center );
				}

				cavity /= sampleOffsets.Length;
				var ao = 1f - cavity * strength * 2f;
				values[index] = Math.Clamp( ao, minimum, 1f );
			}
		}

		return values;
	}

	private static float[] CreateFlatValues( int count, float value )
	{
		var values = new float[count];
		var clamped = SeamlessSuiteImageUtility.Clamp01( value );

		for ( var i = 0; i < values.Length; i++ )
		{
			values[i] = clamped;
		}

		return values;
	}

	private static Color[] CreateGrayscalePixels( float[] values )
	{
		var pixels = new Color[values.Length];

		for ( var i = 0; i < values.Length; i++ )
		{
			var value = SeamlessSuiteImageUtility.Clamp01( values[i] );
			pixels[i] = new Color( value, value, value, 1f );
		}

		return pixels;
	}

	private static Color[] CreateNormalPixels( float[] heightValues, int width, int height, PbrGeneratorSettings settings )
	{
		var pixels = new Color[heightValues.Length];
		var strength = MathF.Max( 0f, settings.NormalStrength );

		for ( var y = 0; y < height; y++ )
		{
			for ( var x = 0; x < width; x++ )
			{
				var left = SampleWrapped( heightValues, width, height, x - 1, y );
				var right = SampleWrapped( heightValues, width, height, x + 1, y );
				var up = SampleWrapped( heightValues, width, height, x, y - 1 );
				var down = SampleWrapped( heightValues, width, height, x, y + 1 );

				var slopeX = (left - right) * strength;
				var slopeY = (up - down) * strength;

				if ( settings.FlipNormalY )
					slopeY = -slopeY;

				var normalLength = MathF.Sqrt( slopeX * slopeX + slopeY * slopeY + 1f );
				var nx = slopeX / normalLength;
				var ny = slopeY / normalLength;
				var nz = 1f / normalLength;

				pixels[y * width + x] = new Color(
					nx * 0.5f + 0.5f,
					ny * 0.5f + 0.5f,
					nz * 0.5f + 0.5f,
					1f
				);
			}
		}

		return pixels;
	}

	private static Color[] CreateOrmPixels( float[] aoValues, float[] roughnessValues, float[] metallicValues )
	{
		var pixels = new Color[aoValues.Length];

		for ( var i = 0; i < pixels.Length; i++ )
		{
			pixels[i] = new Color(
				SeamlessSuiteImageUtility.Clamp01( aoValues[i] ),
				SeamlessSuiteImageUtility.Clamp01( roughnessValues[i] ),
				SeamlessSuiteImageUtility.Clamp01( metallicValues[i] ),
				1f
			);
		}

		return pixels;
	}

	private static Bitmap CreateBitmap( Color[] pixels, int width, int height, bool floatingPoint )
	{
		if ( pixels == null || pixels.Length < width * height )
			return null;

		var bitmap = new Bitmap( width, height, floatingPoint );
		bitmap.SetPixels( pixels );
		return bitmap;
	}

	private static float[] FastBlurValues( float[] values, int width, int height, float radius )
	{
		if ( radius <= 0.01f )
			return (float[])values.Clone();

		var blur = values;
		var boxRadii = GetGaussianBoxRadii( radius, 3 );

		foreach ( var boxRadius in boxRadii )
		{
			if ( boxRadius <= 0 )
				continue;

			blur = FastBoxBlurValues( blur, width, height, boxRadius );
		}

		return ReferenceEquals( blur, values ) ? (float[])values.Clone() : blur;
	}

	private static int[] GetGaussianBoxRadii( float sigma, int passCount )
	{
		sigma = MathF.Max( 0.01f, sigma );
		var idealWidth = MathF.Sqrt( 12f * sigma * sigma / passCount + 1f );
		var lowerWidth = (int)MathF.Floor( idealWidth );

		if ( lowerWidth % 2 == 0 )
			lowerWidth--;

		lowerWidth = Math.Max( 1, lowerWidth );
		var upperWidth = lowerWidth + 2;
		var passSplit = (int)MathF.Round(
			(12f * sigma * sigma - passCount * lowerWidth * lowerWidth - 4f * passCount * lowerWidth - 3f * passCount)
			/ (-4f * lowerWidth - 4f)
		);
		passSplit = Math.Clamp( passSplit, 0, passCount );

		var radii = new int[passCount];

		for ( var i = 0; i < passCount; i++ )
		{
			var width = i < passSplit ? lowerWidth : upperWidth;
			radii[i] = Math.Max( 0, (width - 1) / 2 );
		}

		return radii;
	}

	private static float[] BoxBlurValues( float[] values, int width, int height, int radius )
	{
		if ( radius <= 0 )
			return (float[])values.Clone();

		var blurred = new float[values.Length];
		var diameter = radius * 2 + 1;
		var sampleCount = diameter * diameter;

		for ( var y = 0; y < height; y++ )
		{
			for ( var x = 0; x < width; x++ )
			{
				var sum = 0f;

				for ( var sampleY = -radius; sampleY <= radius; sampleY++ )
				{
					for ( var sampleX = -radius; sampleX <= radius; sampleX++ )
					{
						sum += SampleWrapped( values, width, height, x + sampleX, y + sampleY );
					}
				}

				blurred[y * width + x] = sum / sampleCount;
			}
		}

		return blurred;
	}

	private static float[] FastBoxBlurValues( float[] values, int width, int height, int radius )
	{
		if ( radius <= 0 )
			return (float[])values.Clone();

		var diameter = radius * 2 + 1;
		var horizontal = new float[values.Length];
		var blurred = new float[values.Length];

		for ( var y = 0; y < height; y++ )
		{
			var row = y * width;
			var sum = 0f;

			for ( var sampleX = -radius; sampleX <= radius; sampleX++ )
			{
				var sourceX = Math.Clamp( sampleX, 0, width - 1 );
				sum += values[row + sourceX];
			}

			for ( var x = 0; x < width; x++ )
			{
				horizontal[row + x] = sum / diameter;

				var removeX = Math.Clamp( x - radius, 0, width - 1 );
				var addX = Math.Clamp( x + radius + 1, 0, width - 1 );
				sum += values[row + addX] - values[row + removeX];
			}
		}

		for ( var x = 0; x < width; x++ )
		{
			var sum = 0f;

			for ( var sampleY = -radius; sampleY <= radius; sampleY++ )
			{
				var sourceY = Math.Clamp( sampleY, 0, height - 1 );
				sum += horizontal[sourceY * width + x];
			}

			for ( var y = 0; y < height; y++ )
			{
				blurred[y * width + x] = sum / diameter;

				var removeY = Math.Clamp( y - radius, 0, height - 1 );
				var addY = Math.Clamp( y + radius + 1, 0, height - 1 );
				sum += horizontal[addY * width + x] - horizontal[removeY * width + x];
			}
		}

		return blurred;
	}

	private static float SampleWrapped( float[] values, int width, int height, int x, int y )
	{
		var wrappedX = SeamlessSuiteImageUtility.WrapIndex( x, width );
		var wrappedY = SeamlessSuiteImageUtility.WrapIndex( y, height );
		return values[wrappedY * width + wrappedX];
	}

	private static float GetWrappedValueNoise( int x, int y, int width, int height )
	{
		var gridX = Math.Clamp( width / 48, 4, 32 );
		var gridY = Math.Clamp( height / 48, 4, 32 );
		var u = x / Math.Max( 1f, width ) * gridX;
		var v = y / Math.Max( 1f, height ) * gridY;
		var x0 = (int)MathF.Floor( u );
		var y0 = (int)MathF.Floor( v );
		var xBlend = SmoothStep( u - x0 );
		var yBlend = SmoothStep( v - y0 );

		var a = Hash01Wrapped( x0, y0, gridX, gridY );
		var b = Hash01Wrapped( x0 + 1, y0, gridX, gridY );
		var c = Hash01Wrapped( x0, y0 + 1, gridX, gridY );
		var d = Hash01Wrapped( x0 + 1, y0 + 1, gridX, gridY );
		var top = SeamlessSuiteImageUtility.Lerp( a, b, xBlend );
		var bottom = SeamlessSuiteImageUtility.Lerp( c, d, xBlend );

		return SeamlessSuiteImageUtility.Lerp( top, bottom, yBlend );
	}

	private static float Hash01Wrapped( int x, int y, int gridX, int gridY )
	{
		x = SeamlessSuiteImageUtility.WrapIndex( x, gridX );
		y = SeamlessSuiteImageUtility.WrapIndex( y, gridY );

		unchecked
		{
			var hash = x * 374761393 + y * 668265263;// random large numbers for noise generation
			hash = (hash ^ (hash >> 13)) * 1274126177;
			return ((hash ^ (hash >> 16)) & 0x7fffffff) / (float)int.MaxValue;
		}
	}

	private static float SmoothStep( float value )
	{
		value = SeamlessSuiteImageUtility.Clamp01( value );
		return value * value * (3f - 2f * value);
	}
}