Editor/Core/UnitySky.cs

Editor helper that detects Unity skybox materials and converts their properties into s&box sky representations. It recognizes 6-sided, cubemap, panoramic and procedural skies, computes tint/rotation, determines cube image layouts and provides utilities to map panorama UV to cube faces and to produce vmat text.

File Access
using System;
using System.Collections.Generic;
using System.Linq;

namespace ImportUnityPackage;

/// <summary>
/// Unity skybox materials (Skybox/6 Sided, Cubemap, Panoramic and Procedural, or a custom shader with one of their
/// property layouts) as s&amp;box sky materials. s&amp;box skies sample a latitude-longitude panorama. Unity's panorama
/// layout is s&amp;box's turned a quarter around the vertical axis, which the sky object's yaw restores together with
/// Unity's <c>_Rotation</c>, so Unity panoramas (HDR ones included) are used unchanged and cube faces are composed into
/// Unity's panorama layout.
/// </summary>
internal sealed class UnitySky
{
	internal enum Kind { Procedural, Panorama, Faces }
	/// <summary>How a cube texture or panoramic image lays out the sky.</summary>
	internal enum Layout { LatLong, HorizontalCross, VerticalCross, HorizontalStrip, VerticalStrip }

	public Kind Type { get; private init; }
	/// <summary>Panorama: the texture GUID.</summary>
	public string Texture { get; private init; }
	/// <summary>Panorama: true for Skybox/Panoramic's 6 Frames Layout, which reads the image as a cube layout.</summary>
	public bool Frames { get; private init; }
	/// <summary>Skybox/Panoramic: its texture is a 2D image, latitude-longitude at any aspect unless <see cref="Frames"/>.</summary>
	public bool Panoramic { get; private init; }
	/// <summary>Faces: texture GUIDs for +X, −X, +Y, −Y, +Z, −Z.</summary>
	public string[] Faces { get; private init; }
	/// <summary>Unity's rotation around the vertical axis, in degrees.</summary>
	public double Rotation { get; private init; }
	/// <summary>Linear color multiplier: Unity's _Tint in linear color times 4.59 (Unity's linear-space doubling, so its grey default is neutral) times _Exposure.</summary>
	public double[] Tint { get; private init; }
	/// <summary>Why the material is a sky that cannot be converted, or null.</summary>
	public string Problem { get; private init; }
	/// <summary>The yaw of the s&amp;box sky object.</summary>
	public double Yaw => Type == Kind.Procedural ? 0 : Math.Round( 90 - Rotation, 3 );

	// Skybox/6 Sided draws _LeftTex on +X and _RightTex on −X.
	static readonly string[] FaceProperties = { "_LeftTex", "_RightTex", "_UpTex", "_DownTex", "_FrontTex", "_BackTex" };

	/// <summary>The sky a material describes, or null when it is not a skybox material.</summary>
	internal static UnitySky Detect( UnityMaterial material )
	{
		var builtin = (material.ShaderGuid ?? "").Equals( UnityKnownShaders.BuiltinGuid, StringComparison.OrdinalIgnoreCase ) ? material.ShaderFileId : 0;
		var declaredSky = (material.ShaderName ?? "").StartsWith( "Skybox/", StringComparison.OrdinalIgnoreCase );
		var tint = Color( material );
		var rotation = material.Number( "_Rotation", 0 );
		if ( builtin == 104 || FaceProperties.All( material.Textures.ContainsKey ) )
		{
			var missing = FaceProperties.Where( p => !material.Textures.ContainsKey( p ) ).ToArray();
			return new()
			{
				Type = Kind.Faces, Faces = FaceProperties.Select( p => material.Textures.GetValueOrDefault( p ) ).ToArray(), Tint = tint, Rotation = rotation,
				Problem = missing.Length > 0 ? $"The 6-sided skybox has no texture for {string.Join( ", ", missing )}." : null
			};
		}
		if ( builtin == 103 || declaredSky && material.Textures.ContainsKey( "_Tex" ) )
			return new() { Type = Kind.Panorama, Texture = material.Textures.GetValueOrDefault( "_Tex" ), Tint = tint, Rotation = rotation, Problem = material.Textures.ContainsKey( "_Tex" ) ? null : "The cubemap skybox has no cubemap." };
		if ( builtin == 108 || declaredSky && material.Textures.ContainsKey( "_MainTex" ) && material.Numbers.ContainsKey( "_Mapping" ) )
		{
			var problem = !material.Textures.ContainsKey( "_MainTex" ) ? "The panoramic skybox has no texture." :
				material.Number( "_ImageType", 0 ) == 1 ? "The panoramic skybox covers 180 degrees; only 360-degree panoramas are converted." : null;
			return new() { Type = Kind.Panorama, Panoramic = true, Texture = material.Textures.GetValueOrDefault( "_MainTex" ), Frames = material.Number( "_Mapping", 1 ) == 0, Tint = tint, Rotation = rotation, Problem = problem };
		}
		// Skybox/Procedural, and custom atmosphere skies with its layout, compute the sky from the sun.
		if ( builtin == 106 || material.Textures.Count == 0 && material.Numbers.ContainsKey( "_AtmosphereThickness" ) && material.Colors.ContainsKey( "_SkyTint" ) )
			return new() { Type = Kind.Procedural };
		return null;
	}

	/// <summary>
	/// Unity skybox shaders multiply by _Tint times unity_ColorSpaceDouble (2 in gamma, about 4.59 linear) and _Exposure,
	/// so the default grey tint is neutral. Material colors are stored in gamma space.
	/// </summary>
	static double[] Color( UnityMaterial material )
	{
		var tint = material.Colors.GetValueOrDefault( "_Tint" ) ?? new[] { 0.5, 0.5, 0.5, 1 };
		var exposure = Math.Max( 0, material.Number( "_Exposure", 1 ) );
		static double Linear( double c ) => c <= 0.04045 ? c / 12.92 : Math.Pow( (c + 0.055) / 1.055, 2.4 );
		return tint.Take( 3 ).Select( c => Math.Round( Linear( Math.Clamp( c, 0, 1 ) ) * 4.59479 * exposure, 4 ) ).ToArray();
	}

	/// <summary>
	/// How Unity reads a cube texture (generateCubemap: 1 sphere map, 2 cylindrical, 5 full cubemap and 6 automatic,
	/// which go by aspect ratio) or a 6 Frames Layout panorama. Images of unknown size (HDR files) are taken as
	/// latitude-longitude, as Unity HDRIs are; null when Unity's layout is a sphere map or not recognized.
	/// </summary>
	internal static Layout? CubeLayout( (int Width, int Height)? size, int generate, bool frames = false )
	{
		if ( !frames && generate == 2 ) return Layout.LatLong;
		if ( !frames && generate is 1 or 3 or 4 ) return null;
		if ( size is not var (w, h) ) return frames ? null : Layout.LatLong;
		if ( !frames && w == 2 * h ) return Layout.LatLong;
		if ( w * 3 == h * 4 ) return Layout.HorizontalCross;
		if ( w * 4 == h * 3 ) return Layout.VerticalCross;
		if ( w == 6 * h ) return Layout.HorizontalStrip;
		if ( h == 6 * w ) return Layout.VerticalStrip;
		return null;
	}

	/// <summary>The six faces (+X, −X, +Y, −Y, +Z, −Z) of a cube layout in one image.</summary>
	internal static SkyFace[] Regions( Layout layout, string source, int width )
	{
		var (columns, rows, cells) = layout switch
		{
			Layout.HorizontalStrip => (6, 1, new[] { (0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0) }),
			Layout.VerticalStrip => (1, 6, new[] { (0, 0), (0, 1), (0, 2), (0, 3), (0, 4), (0, 5) }),
			// +Y above +Z, with −X, +Z, +X, −Z left to right and −Y below.
			Layout.HorizontalCross => (4, 3, new[] { (2, 1), (0, 1), (1, 0), (1, 2), (1, 1), (3, 1) }),
			// −Z hangs below −Y, upside down.
			Layout.VerticalCross => (3, 4, new[] { (2, 1), (0, 1), (1, 0), (1, 2), (1, 1), (1, 3) }),
			_ => throw new ArgumentOutOfRangeException( nameof( layout ) )
		};
		var size = width / columns;
		return cells.Select( ( c, i ) => new SkyFace( source, c.Item1 * size, c.Item2 * size, size, size, layout == Layout.VerticalCross && i == 5 ) ).ToArray();
	}

	/// <summary>
	/// s&amp;box's standard day sky, for procedural skies (Unity's Default-Skybox and skies with its layout). s&amp;box's
	/// atmosphere sky would follow the sun like Unity's, but it has no sky texture, so a SkyBox2D's indirect lighting would
	/// give the scene no ambient light or reflections.
	/// </summary>
	internal const string DefaultSky = "materials/skybox/skybox_day_01.vmat";

	/// <summary>A sky material showing a latitude-longitude panorama.</summary>
	internal static string Vmat( string image ) => $"Layer0\n{{\n\tshader \"shaders/sky.shader\"\n\tSkyTexture {UnityMaterial.Quote( image )}\n}}\n";

	/// <summary>
	/// The panorama pixel's direction in Unity space (Skybox/Panoramic's mapping: u = 0.5 − atan2(z, x) / 2π, the top
	/// row looking up) and the cube face texel it reads (the Direct3D face table Unity uses, each face upright from inside).
	/// </summary>
	internal static (int Face, double U, double V) Sample( double u, double v )
	{
		var azimuth = (0.5 - u) * 2 * Math.PI;
		var polar = v * Math.PI;
		var (x, y, z) = (Math.Sin( polar ) * Math.Cos( azimuth ), Math.Cos( polar ), Math.Sin( polar ) * Math.Sin( azimuth ));
		double ax = Math.Abs( x ), ay = Math.Abs( y ), az = Math.Abs( z ), major, s, t;
		int face;
		if ( ax >= ay && ax >= az ) { face = x > 0 ? 0 : 1; major = ax; s = x > 0 ? -z : z; t = -y; }
		else if ( ay >= az ) { face = y > 0 ? 2 : 3; major = ay; s = x; t = y > 0 ? z : -z; }
		else { face = z > 0 ? 4 : 5; major = az; s = z > 0 ? x : -x; t = -y; }
		return (face, (s / major + 1) / 2, (t / major + 1) / 2);
	}
}

/// <summary>A cube face read from a source image: its pixel rectangle (the whole image when empty), upside down when Flip.</summary>
public sealed record SkyFace( string Source, int X, int Y, int Width, int Height, bool Flip );