Editor/Core/UnitySceneFeatures.cs

Part of the Unity to s&box prefab converter, this file converts Unity scene features into s&box equivalents. It converts lights (including pipeline-specific units), cameras, reflection probes, audio sources, and scene render settings (sky, ambient, fog), computing numeric conversions and building JSON components.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;

namespace ImportUnityPackage;

/// <summary>
/// Lights, cameras, reflection probes, audio sources and a scene's render settings (sky, ambient light, fog) as their
/// s&amp;box counterparts. Values are converted, not copied: Unity's three render pipelines measure light differently.
/// </summary>
internal sealed partial class UnityPrefabConverter
{
	/// <summary>Imported sound events by audio clip GUID.</summary>
	public Dictionary<string, string> Sounds { get; init; } = new( StringComparer.OrdinalIgnoreCase );

	const string UrpLightData = "474bcb49853aa07438625e644c072ee6", HdrpLightData = "7a68c43fe1f2a47cfa234b5eeaa98012";
	const double Inch = UnityTransform.InchesPerMeter;

	static double Num( UnityYamlObject part, IReadOnlyDictionary<string, YamlNode> overrides, string property, double fallback ) =>
		(overrides == null ? null : Overrides.Number( overrides, property )) ?? Path( part.Body, property )?.Number ?? fallback;
	static YamlNode Path( YamlNode body, string property ) { foreach ( var key in property.Split( '.' ) ) body = body?[key]; return body; }
	static double[] Rgb( UnityYamlObject part, IReadOnlyDictionary<string, YamlNode> overrides, string property ) =>
		new[] { "r", "g", "b" }.Select( c => Num( part, overrides, $"{property}.{c}", 1 ) ).ToArray();
	static string ColorText( double[] rgb, double alpha = 1 ) => string.Join( ",", rgb.Select( F ) ) + "," + F( alpha );

	/// <summary>Which pipeline a light was authored for, from the additional data component URP and HDRP add to lights.</summary>
	enum Pipeline { BuiltIn, Universal, HighDefinition }
	static Pipeline PipelineOf( IEnumerable<UnityYamlObject> parts )
	{
		var scripts = parts.Where( p => p.ClassId == 114 ).Select( p => Ref( p.Body, "m_Script" ).Guid ).ToHashSet( StringComparer.OrdinalIgnoreCase );
		return scripts.Contains( HdrpLightData ) ? Pipeline.HighDefinition : scripts.Contains( UrpLightData ) ? Pipeline.Universal : Pipeline.BuiltIn;
	}

	/// <summary>
	/// The illuminance (lux) that HDRP's camera exposure maps to full brightness on a white surface. HDRP divides
	/// luminance by 1.2 × 2^EV, and a white surface under E lux has luminance E/π, so this is π × 1.2 × 2^EV with the EV of
	/// the file's highest-priority global volume (its fixed exposure, or the middle of its automatic limits). Without a
	/// volume, the brightest HDRP sun stands in (the sun at full brightness), else an indoor level. HDRP light values
	/// are physical, so they are taken relative to it.
	/// </summary>
	double HdrpReference( Document document )
	{
		if ( document.HdrpReference is double cached ) return cached;
		if ( Exposure( document ) is double ev ) return (document.HdrpReference = Math.PI * 1.2 * Math.Pow( 2, ev )).Value;
		var suns = document.Objects.Values.Where( o => o.ClassId == 108 && !o.Stripped && (o.Body["m_Type"]?.Number ?? 2) == 1 &&
			PipelineOf( document.Components.GetValueOrDefault( Ref( o.Body, "m_GameObject" ).FileId ) ?? new() ) == Pipeline.HighDefinition )
			.Select( o => o.Body["m_Intensity"]?.Number ?? 0 ).Where( v => v > 0 ).ToList();
		return (document.HdrpReference = suns.Count > 0 ? suns.Max() : 1000).Value;
	}

	/// <summary>
	/// The exposure (EV100) of the highest-priority global volume whose profile overrides HDRP's Exposure; without one,
	/// the median of the local volumes' exposures (scenes that switch volumes per area or camera).
	/// </summary>
	double? Exposure( Document document )
	{
		var volumes = document.Objects.Values.Where( o => o.ClassId == 114 && !o.Stripped && o.Body["sharedProfile"] != null && (o.Body["m_Enabled"]?.Number ?? 1) != 0 ).ToList();
		var global = volumes.Where( o => (o.Body["isGlobal"]?.Number ?? 0) != 0 ).OrderByDescending( o => o.Body["priority"]?.Number ?? 0 ).Select( ProfileExposure ).FirstOrDefault( ev => ev != null );
		if ( global != null ) return global;
		var local = volumes.Select( ProfileExposure ).Where( ev => ev != null ).Select( ev => ev.Value ).OrderBy( ev => ev ).ToList();
		return local.Count > 0 ? local[local.Count / 2] : null;
	}

	/// <summary>A volume profile's exposure: its fixed EV, or the middle of its automatic limits, less compensation.</summary>
	double? ProfileExposure( UnityYamlObject volume )
	{
		var profile = Ref( volume.Body, "sharedProfile" );
		if ( !profile.HasGuid || !assets.ContainsKey( profile.Guid ) ) return null;
		IReadOnlyList<UnityYamlObject> objects;
		try { objects = Load( profile.Guid ).Objects.Values.ToList(); }
		catch ( System.IO.InvalidDataException ) { return null; }
		var exposure = objects.FirstOrDefault( o => o.Body?["m_Name"]?.Value == "Exposure" && (o.Body["active"]?.Number ?? 1) != 0 && o.Body["fixedExposure"] != null );
		if ( exposure == null ) return null;
		double? Value( string field ) => (exposure.Body[field]?["m_OverrideState"]?.Number ?? 0) != 0 ? exposure.Body[field]?["m_Value"]?.Number : null;
		var mode = Value( "mode" ) ?? 0;
		var ev = mode == 0 ? Value( "fixedExposure" ) : Value( "limitMin" ) is double low && Value( "limitMax" ) is double high ? (low + high) / 2 : null;
		return ev is double value ? value - (Value( "compensation" ) ?? 0) : null;
	}

	/// <summary>
	/// A Unity light as an s&amp;box light. s&amp;box lights give their color in full up to 100 units away and fall off with
	/// the square of distance beyond, scaled by Attenuation: color × min(1, 10000 / (Attenuation × d²)).
	/// <list type="bullet">
	/// <item>Built-in pipeline lights are brightest at the light and fade to about 1/26 at their range, so they keep color × intensity with an attenuation that matches at the range.</item>
	/// <item>URP lights fall off with the inverse square of distance in meters from their intensity at 1 m, which is s&amp;box's falloff with color = intensity × (100 / 39.37)⁻².</item>
	/// <item>HDRP lights are in candela (lux for the sun), relative to the scene's exposure, taken as its sun's illuminance.</item>
	/// </list>
	/// URP and HDRP lights also take their color temperature. Area lights (baked only in Unity) are not converted.
	/// </summary>
	JsonObject Light( Document document, UnityYamlObject light, List<UnityYamlObject> parts, IReadOnlyDictionary<string, YamlNode> overrides, Summary summary, string space, long salt )
	{
		var type = (int)Num( light, overrides, "m_Type", 2 );
		if ( type is not (0 or 1 or 2) ) { summary.Skip( "area light" ); return null; }
		var pipeline = PipelineOf( parts );
		var color = Rgb( light, overrides, "m_Color" );
		if ( pipeline != Pipeline.BuiltIn && Num( light, overrides, "m_UseColorTemperature", 0 ) != 0 )
			color = color.Zip( Kelvin( Num( light, overrides, "m_ColorTemperature", 6570 ) ), ( a, b ) => a * b ).ToArray();
		var intensity = Math.Max( 0, Num( light, overrides, "m_Intensity", 1 ) );
		var range = Math.Max( 0.01, Num( light, overrides, "m_Range", 10 ) ) * Inch;
		double brightness = intensity, attenuation = 1;
		const double unitFalloff = 100 / Inch; // s&box's full-strength distance in meters
		if ( type == 1 ) brightness = pipeline == Pipeline.HighDefinition ? intensity / HdrpReference( document ) : intensity;
		else if ( pipeline == Pipeline.BuiltIn ) attenuation = 260000 / (range * range);
		else brightness = intensity / (unitFalloff * unitFalloff) / (pipeline == Pipeline.HighDefinition ? HdrpReference( document ) : 1);
		var kind = type switch { 0 => "SpotLight", 1 => "DirectionalLight", _ => "PointLight" };
		var result = Component( kind, Id( space, light.FileId ^ salt, "light" ), Num( light, overrides, "m_Enabled", 1 ) != 0 );
		result["LightColor"] = ColorText( color.Select( c => c * brightness ).ToArray() );
		result["Shadows"] = Num( light, overrides, "m_Shadows.m_Type", 0 ) != 0;
		if ( type != 1 ) { result["Radius"] = Math.Round( range, 3 ); result["Attenuation"] = Math.Round( attenuation, 6 ); }
		if ( type == 0 )
		{
			// Unity's angles are the full cone; s&box's are half angles. Built-in spots fade from the axis to the edge.
			var outer = Num( light, overrides, "m_SpotAngle", 30 ) / 2;
			result["ConeOuter"] = Math.Round( outer, 3 );
			result["ConeInner"] = Math.Round( pipeline == Pipeline.BuiltIn ? 0 : Math.Min( outer, Num( light, overrides, "m_InnerSpotAngle", outer * 2 * 0.8 ) / 2 ), 3 );
		}
		if ( type != 1 && pipeline != Pipeline.BuiltIn && brightness > 64 )
			summary.Notes.Add( "Lights brighter than s&box usually uses (URP and HDRP intensities fall off over meters) were converted as measured; adjust their color if they glare." );
		summary.Lights++;
		return result;
	}

	/// <summary>The RGB tint of a black body at <paramref name="kelvin"/>, normalized to 1 (6500 K is about white).</summary>
	static double[] Kelvin( double kelvin )
	{
		var t = Math.Clamp( kelvin, 1000, 40000 ) / 100;
		double r = t <= 66 ? 255 : 329.698727446 * Math.Pow( t - 60, -0.1332047592 );
		double g = t <= 66 ? 99.4708025861 * Math.Log( t ) - 161.1195681661 : 288.1221695283 * Math.Pow( t - 60, -0.0755148492 );
		double b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.Log( t - 10 ) - 305.0447927307;
		return new[] { r, g, b }.Select( c => Math.Clamp( c, 0, 255 ) / 255 ).ToArray();
	}

	/// <summary>A Unity camera: vertical field of view, clip planes, background color and the MainCamera tag.</summary>
	JsonObject Camera( UnityYamlObject camera, UnityYamlObject go, IReadOnlyDictionary<string, YamlNode> overrides, Summary summary, string space, long salt )
	{
		var result = Component( "CameraComponent", Id( space, camera.FileId ^ salt, "camera" ), Num( camera, overrides, "m_Enabled", 1 ) != 0 );
		result["FovAxis"] = "Vertical";
		result["FieldOfView"] = Math.Round( Num( camera, overrides, "field of view", 60 ), 3 );
		result["ZNear"] = Math.Round( Math.Max( 0.01, Num( camera, overrides, "near clip plane", 0.3 ) ) * Inch, 3 );
		result["ZFar"] = Math.Round( Num( camera, overrides, "far clip plane", 1000 ) * Inch, 3 );
		result["Orthographic"] = Num( camera, overrides, "orthographic", 0 ) != 0;
		result["OrthographicHeight"] = Math.Round( Num( camera, overrides, "orthographic size", 5 ) * 2 * Inch, 3 );
		result["Priority"] = (int)Math.Round( Num( camera, overrides, "m_Depth", 0 ) );
		result["IsMainCamera"] = go?.Body["m_TagString"]?.Value == "MainCamera";
		if ( (int)Num( camera, overrides, "m_ClearFlags", 1 ) == 2 ) result["BackgroundColor"] = ColorText( Rgb( camera, overrides, "m_BackGroundColor" ) );
		summary.Cameras++;
		return result;
	}

	/// <summary>
	/// A reflection probe as an envmap probe over the same box. s&amp;box renders it when enabled, standing in for Unity's
	/// baked or realtime cubemap; box projection carries over.
	/// </summary>
	JsonObject ReflectionProbe( UnityYamlObject probe, IReadOnlyDictionary<string, YamlNode> overrides, Summary summary, string space, long salt )
	{
		var size = new[] { "x", "y", "z" }.Select( a => Math.Abs( Num( probe, overrides, $"m_BoxSize.{a}", 10 ) ) ).ToArray();
		var offset = new[] { "x", "y", "z" }.Select( a => Num( probe, overrides, $"m_BoxOffset.{a}", 0 ) ).ToArray();
		// Unity (x, y, z) is s&box (z, -x, y).
		var center = new[] { offset[2], -offset[0], offset[1] }.Select( v => v * Inch ).ToArray();
		var half = new[] { size[2], size[0], size[1] }.Select( v => v / 2 * Inch ).ToArray();
		var result = Component( "EnvmapProbe", Id( space, probe.FileId ^ salt, "probe" ), Num( probe, overrides, "m_Enabled", 1 ) != 0 );
		result["Mode"] = "Realtime";
		result["UpdateStrategy"] = "OnEnabled";
		result["Projection"] = Num( probe, overrides, "m_BoxProjection", 0 ) != 0 ? "Box" : "Sphere";
		result["Bounds"] = new JsonObject { ["Mins"] = Vec( center.Zip( half, ( c, h ) => c - h ).ToArray() ), ["Maxs"] = Vec( center.Zip( half, ( c, h ) => c + h ).ToArray() ) };
		result["Priority"] = (int)Num( probe, overrides, "m_Importance", 1 );
		result["Feathering"] = Math.Round( Math.Clamp( Num( probe, overrides, "m_BlendDistance", 1 ) * 2 / Math.Max( 0.01, size.Min() ), 0, 1 ), 3 );
		summary.Probes++;
		return result;
	}

	/// <summary>An audio source playing an imported clip's sound event, with its volume, pitch, looping, 2D/3D blend and range.</summary>
	JsonObject AudioSource( UnityYamlObject source, IReadOnlyDictionary<string, YamlNode> overrides, Summary summary, string space, long salt )
	{
		var clip = Ref( source.Body, "m_audioClip" );
		if ( overrides != null && Overrides.Object( overrides, "m_audioClip" ) is { HasGuid: true } swapped ) clip = swapped;
		if ( !clip.HasGuid || !Sounds.TryGetValue( clip.Guid, out var sound ) )
		{
			summary.Skip( clip.HasGuid ? "AudioSource with a clip that is not imported" : "AudioSource without a clip" );
			return null;
		}
		var result = Component( "SoundPointComponent", Id( space, source.FileId ^ salt, "sound" ), Num( source, overrides, "m_Enabled", 1 ) != 0 );
		result["SoundEvent"] = sound;
		result["PlayOnStart"] = Num( source, overrides, "m_PlayOnAwake", 1 ) != 0;
		result["Volume"] = Math.Round( Num( source, overrides, "m_Volume", 1 ), 4 );
		result["Pitch"] = Math.Round( Num( source, overrides, "m_Pitch", 1 ), 4 );
		// Looping: replay as soon as the sound ends.
		var loop = Num( source, overrides, "Loop", 0 ) != 0;
		result["Repeat"] = loop; result["MinRepeatTime"] = 0; result["MaxRepeatTime"] = 0;
		// Spatial blend 0 (Unity's default) plays the sound without position.
		var blend = source.Body["panLevelCustomCurve"]?["m_Curve"]?.Items.FirstOrDefault()?["value"]?.Number ?? 0;
		result["Force2d"] = blend < 0.5;
		result["DistanceAttenuationOverride"] = true; result["DistanceAttenuation"] = true;
		result["Distance"] = Math.Round( Num( source, overrides, "MaxDistance", 500 ) * Inch, 3 );
		summary.Sounds++;
		return result;
	}

	/// <summary>
	/// The scene's render settings as an Environment object: the converted skybox (lighting the scene when Unity's
	/// ambient light comes from it; s&amp;box's default sky when the material was not converted), a flat ambient color
	/// otherwise, and distance fog. HDRP scenes keep their sky and fog in volume profiles, which are not read.
	/// </summary>
	JsonObject Environment( Document document, Summary summary, string space )
	{
		var settings = document.Objects.Values.FirstOrDefault( o => o.ClassId == 104 && !o.Stripped );
		if ( settings == null ) return null;
		double N( string property, double fallback ) => Num( settings, null, property, fallback );
		var node = GameObject( Id( space, settings.FileId, "environment" ), "Environment", true );
		var components = node["Components"].AsArray();
		var ambientMode = (int)N( "m_AmbientMode", 0 );
		var intensity = N( "m_AmbientIntensity", 1 );
		var sky = Ref( settings.Body, "m_SkyboxMaterial" );
		if ( sky.FileId != 0 )
		{
			var skybox = Component( "SkyBox2D", Id( space, settings.FileId, "sky" ), true );
			skybox["SkyMaterial"] = UnitySky.DefaultSky;
			skybox["Tint"] = "1,1,1,1";
			skybox["SkyIndirectLighting"] = ambientMode == 0;
			components.Add( skybox );
			if ( sky.HasGuid && Skies.TryGetValue( sky.Guid, out var converted ) )
			{
				skybox["SkyMaterial"] = converted.Material;
				if ( converted.Tint != null ) skybox["Tint"] = ColorText( converted.Tint );
				// The sky object's yaw turns the panorama (see UnitySky).
				var half = converted.Yaw * Math.PI / 360;
				if ( converted.Yaw != 0 ) node["Rotation"] = Vec( new[] { 0, 0, Math.Sin( half ), Math.Cos( half ) } );
				summary.Skies++;
			}
			else if ( (sky.Guid ?? "").Equals( UnityKnownShaders.BuiltinGuid, StringComparison.OrdinalIgnoreCase ) )
			{
				// Unity's Default-Skybox material is Skybox/Procedural, shown as s&box's standard day sky (see UnitySky.DefaultSky).
				summary.Skies++;
			}
			else summary.Notes.Add( "The Unity skybox is shown as s&box's default sky; its material was not converted." );
		}
		if ( ambientMode != 0 || sky.FileId == 0 )
		{
			var ambient = Component( "AmbientLight", Id( space, settings.FileId, "ambient" ), true );
			var color = ambientMode == 0 ? new[] { 0.212, 0.227, 0.259 } : Rgb( settings, null, "m_AmbientSkyColor" );
			// The intensity multiplier belongs to skybox and custom ambient; a flat or gradient color is used as it is.
			ambient["Color"] = ColorText( color.Select( c => c * (ambientMode is 0 or 4 ? intensity : 1) ).ToArray() );
			components.Add( ambient );
		}
		if ( N( "m_Fog", 0 ) != 0 )
		{
			var fog = Component( "GradientFog", Id( space, settings.FileId, "fog" ), true );
			var mode = (int)N( "m_FogMode", 3 );
			var density = Math.Max( 1e-6, N( "m_FogDensity", 0.01 ) );
			// Linear fog runs from its start to its end; exponential fog is 95% thick at 3/density (√3/density squared).
			var (start, end, exponent) = mode == 1 ? (N( "m_LinearFogStart", 0 ), N( "m_LinearFogEnd", 300 ), 1.0) : mode == 2 ? (0, 3 / density, 0.6) : (0, Math.Sqrt( 3 ) / density, 1.5);
			fog["Color"] = ColorText( Rgb( settings, null, "m_FogColor" ) );
			fog["StartDistance"] = Math.Round( start * Inch, 3 );
			fog["EndDistance"] = Math.Round( Math.Max( start + 1, end ) * Inch, 3 );
			fog["FalloffExponent"] = exponent;
			// Unity's distance fog is the same at every height.
			fog["Height"] = 1000000; fog["VerticalFalloffExponent"] = 1;
			components.Add( fog );
		}
		return components.Count > 0 ? node : null;
	}
}