s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (22 total matches found)
anakin.flatpack / FlatpackSystem.cs
Game library
using System;
using System.Collections.Generic;
using Sandbox;

namespace Flatpack;

/// <summary>
/// How hard Flatpack works, for a game's graphics settings: high quality presets turn it off, low ones lean on it.
/// Set <see cref="FlatpackSystem.Level"/>; every Flatpack Renderer follows it within a few frames, nothing re-bakes.
/// </summary>
public enum FlatpackLevel
{
	/// <summary>Real models everywhere; cards never show (a game's High or Ultra preset).</summary>
	Off,

	/// <summary>Swap where the bake was measured to look like the model (the default; a game's Medium).</summary>
	Balanced,

	/// <summary>Swap at 0.6 times the distance, from 3 bounding radii (a game's Low).</summary>
	Performance,

	/// <summary>Swap at 0.35 times the distance, from 1.5 bounding radii (a game's lowest preset or weak hardware).</summary>
	Maximum,
}

/// <summary>
/// Runs the model/card distance check for every <see cref="FlatpackRenderer"/> in the scene.
/// </summary>
/// <remarks>
/// One loop per frame instead of one Update per renderer. The loop is time-sliced: each frame checks at most
/// <see cref="RenderersPerFrame"/> renderers, round-robin, so a forest of 5000 trees spreads its checks over a few
/// frames. Renderers in the middle of a cross-fade are ticked every frame until they finish.
/// </remarks>
public sealed class FlatpackSystem : GameObjectSystem<FlatpackSystem>
{
	/// <summary>
	/// Global level for every Flatpack Renderer, meant to be driven by a game's graphics settings, for example
	/// <c>FlatpackSystem.Level = FlatpackLevel.Performance;</c> on the Low preset. Applies to automatic and manual swap
	/// distances alike; Force Model and Force Card modes ignore it.
	/// </summary>
	public static FlatpackLevel Level { get; set; } = FlatpackLevel.Balanced;

	/// <summary>
	/// What <see cref="Level"/> does to a swap distance: a multiplier, and the closest swap allowed in bounding radii
	/// (0 = the renderer's own minimum). <see cref="FlatpackLevel.Off"/> pushes the swap out to infinity.
	/// </summary>
	public static (float Scale, float MinimumRadii) LevelScaling => Level switch
	{
		FlatpackLevel.Off => (float.PositiveInfinity, 0.0f),
		FlatpackLevel.Performance => (0.6f, 3.0f),
		FlatpackLevel.Maximum => (0.35f, 1.5f),
		_ => (1.0f, 0.0f),
	};

	/// <summary>
	/// Distance checks per frame. Lower spreads the work thinner but makes swaps lag behind a fast camera.
	/// </summary>
	public int RenderersPerFrame { get; set; } = 2048;

	/// <summary>
	/// Number of registered renderers.
	/// </summary>
	public int Count => _renderers.Count;

	readonly List<FlatpackRenderer> _renderers = new();
	readonly List<FlatpackRenderer> _fading = new();
	readonly Dictionary<FlatpackImpostor, int> _visibleCards = new();
	int _cursor;

	public FlatpackSystem( Scene scene ) : base( scene )
	{
		// After the scene update, so this frame's camera and object movement are already in.
		Listen( Stage.FinishUpdate, 100, Tick, "Flatpack.DistancePass" );
	}

	internal void Register( FlatpackRenderer renderer )
	{
		if ( renderer.SystemIndex >= 0 )
			return;

		renderer.SystemIndex = _renderers.Count;
		_renderers.Add( renderer );
	}

	internal void Unregister( FlatpackRenderer renderer )
	{
		var index = renderer.SystemIndex;
		if ( index < 0 || index >= _renderers.Count || _renderers[index] != renderer )
			return;

		// Swap-remove keeps this O(1); the moved renderer takes over the freed slot.
		var last = _renderers[^1];
		_renderers[index] = last;
		last.SystemIndex = index;
		_renderers.RemoveAt( _renderers.Count - 1 );

		renderer.SystemIndex = -1;
		_fading.Remove( renderer );
	}

	/// <summary>
	/// Reference count of visible cards per impostor, kept by <see cref="FlatpackRenderer"/>, so the tick can keep
	/// those atlases streamed in without walking every renderer.
	/// </summary>
	internal void CardVisibilityChanged( FlatpackImpostor impostor, bool visible )
	{
		if ( impostor is null )
			return;

		_visibleCards.TryGetValue( impostor, out var count );
		count += visible ? 1 : -1;

		if ( count > 0 )
			_visibleCards[impostor] = count;
		else
			_visibleCards.Remove( impostor );
	}

	void Tick()
	{
		foreach ( var impostor in _visibleCards.Keys )
			impostor.MarkAtlasesUsed();

		if ( _renderers.Count == 0 )
			return;

		var camera = Scene.Camera;
		if ( !camera.IsValid() )
			return;

		var cameraPosition = camera.WorldPosition;
		var count = Math.Min( RenderersPerFrame, _renderers.Count );

		for ( var i = 0; i < count; i++ )
		{
			if ( _cursor >= _renderers.Count )
				_cursor = 0;

			var renderer = _renderers[_cursor++];
			var distanceSquared = cameraPosition.DistanceSquared( renderer.WorldPosition );

			if ( renderer.Evaluate( distanceSquared ) && !_fading.Contains( renderer ) )
				_fading.Add( renderer );
		}

		if ( _fading.Count == 0 )
			return;

		var delta = Time.Delta;
		for ( var i = _fading.Count - 1; i >= 0; i-- )
		{
			if ( !_fading[i].TickFade( delta ) )
				_fading.RemoveAt( i );
		}
	}
}
anakin.flatpack / Editor/FlatpackPfm.cs
Editor library
using System.IO;
using System.Text;

namespace Flatpack.EditorTools;

/// <summary>
/// Writes greyscale PFM (portable float map) files. The baker stores the 16-bit depth atlas this way: s&amp;box's texture
/// compiler reads PNGs at 8 bits whatever their bit depth, but keeps a float source's precision when it builds an R16
/// texture (measured in iteration 8: every value of a 16-bit ramp came back exact).
/// </summary>
static class FlatpackPfm
{
	/// <summary>
	/// Write <paramref name="pixels"/> (row by row, top row first) as a <paramref name="width"/> x
	/// <paramref name="height"/> single-channel float image.
	/// </summary>
	/// <remarks>
	/// The PFM format stores rows bottom first, but the s&amp;box texture compiler reads the first row as the top, so rows
	/// go out in image order.
	/// </remarks>
	public static void WriteGray( string path, float[] pixels, int width, int height )
	{
		using var file = File.Create( path );
		using var writer = new BinaryWriter( file );

		// "Pf" is one channel; a negative scale means little-endian floats.
		writer.Write( Encoding.ASCII.GetBytes( $"Pf\n{width} {height}\n-1.0\n" ) );
		for ( var i = 0; i < width * height; i++ )
			writer.Write( pixels[i] );
	}
}
anakin.flatpack / Editor/FlatpackBaker.cs
Editor library
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;

namespace Flatpack.EditorTools;

/// <summary>
/// Bakes a model into a Flatpack impostor: albedo, normal, depth and roughness + AO atlases, a frame lookup texture, a
/// material using <c>flatpack_impostor.shader</c>, and a <see cref="FlatpackImpostor"/> resource that ties them together.
/// </summary>
/// <remarks>
/// <para>
/// The model is rendered alone in a throwaway scene with an orthographic camera, once per frame of the
/// N x N grid. Each camera is fitted to that view's outline and depth range (stored in the frame lookup texture), so the
/// object fills its frame. Each pass uses one of the engine's camera debug views, which write material data straight to
/// the target with no lighting, exposure or tonemapping:
/// </para>
/// <list type="bullet">
/// <item>Albedo: the colour texture as sRGB bytes, alpha = coverage.</item>
/// <item>NormalMap: the normal-mapped world normal packed as n * 0.5 + 0.5. The model sits at the origin with no
/// rotation, so world space is object space.</item>
/// <item>Roughness, AmbientOcclusion: linear values as bytes.</item>
/// <item>Depth: there is no depth view, so <c>flatpack_bake_depth.shader</c> goes on as a material override and
/// writes linear depth as albedo (16 bits split over red and green), read back through the Albedo view.</item>
/// </list>
/// </remarks>
public static class FlatpackBaker
{
	/// <summary>
	/// The runtime impostor shader the generated material uses.
	/// </summary>
	public const string ImpostorShader = "shaders/flatpack_impostor.shader";

	const string DepthShader = "shaders/flatpack_bake_depth.shader";

	/// <summary>Opacity at which a supersample counts as covered: the usual alpha-test cut of s&amp;box materials.</summary>
	const byte AlphaTestCut = 128;

	/// <summary>
	/// Bakes whose covered texels are more than this share partly covered are see-through detail. Measured on the test
	/// set: fence 0.75, weed tree 0.50, everything solid or leafy 0.06 to 0.24.
	/// </summary>
	public const float SeeThroughShare = 0.4f;

	/// <summary>
	/// Framed bakes whose covered texels are more than this share partly covered are fine wire mesh and get baked again
	/// unframed. Measured framed in iteration 8: fence 0.59, weed tree 0.42, solid and leafy models 0.10 or less.
	/// </summary>
	const float FineMeshShare = 0.5f;

	/// <summary>
	/// What a bake wrote.
	/// </summary>
	public sealed class Result
	{
		/// <summary>The <c>.fpack</c> resource, loaded.</summary>
		public FlatpackImpostor Impostor { get; init; }

		/// <summary>Asset-relative path of the <c>.fpack</c>.</summary>
		public string ImpostorPath { get; init; }

		/// <summary>Asset-relative path of the generated <c>.vmat</c>.</summary>
		public string MaterialPath { get; init; }

		/// <summary>Absolute paths of every file written.</summary>
		public IReadOnlyList<string> Files { get; init; }

		/// <summary>Wall-clock bake time.</summary>
		public TimeSpan Duration { get; init; }

		/// <summary>The quality level the bake ended up at (Auto picks one).</summary>
		public FlatpackQuality Level { get; init; }

		/// <summary>How closely the card matches the model by distance, when the bake was measured.</summary>
		public FlatpackQualityCheck.Report Measured { get; init; }
	}

	/// <summary>
	/// Bake one model at the settings' quality level, or for <see cref="FlatpackQuality.Auto"/> at the cheapest level that
	/// matches the model from <see cref="FlatpackBakeSettings.AutoTargetRadii"/> outward. Measures the result when
	/// <see cref="FlatpackBakeSettings.MeasureQuality"/> is on (always for Auto) and stores where it holds up.
	/// </summary>
	public static async Task<Result> BakeAsync( Model model, FlatpackBakeSettings settings, Action<float, string> progress = null, CancellationToken cancel = default )
	{
		if ( settings.Quality != FlatpackQuality.Auto )
		{
			var result = await BakeLevelAsync( model, settings, progress, cancel );
			return settings.MeasureQuality ? await MeasureAsync( result, progress, cancel ) : result;
		}

		// Auto: climb the levels until one holds up at the target distance. Each bake overwrites the last.
		var timer = Stopwatch.StartNew();
		var levels = new[] { FlatpackQuality.Low, FlatpackQuality.Medium, FlatpackQuality.High, FlatpackQuality.Ultra };
		var best = FlatpackQuality.Ultra;
		var bestScore = float.MaxValue;
		Result last = null;

		for ( var i = 0; i < levels.Length; i++ )
		{
			var level = levels[i];
			var step = i;
			var baked = await BakeLevelAsync( model, settings.WithQuality( level ),
				( f, text ) => progress?.Invoke( (step + f * 0.8f) / levels.Length, $"{level}: {text}" ), cancel );
			last = await MeasureAsync( baked, ( f, text ) => progress?.Invoke( (step + 0.8f + f * 0.2f) / levels.Length, $"{level}: {text}" ), cancel );

			// See-through detail goes straight to Ultra. At Medium a wire grid has about one texel per wire, so its frames
			// line up badly and the panel doubles when seen at an angle, even where the bake check passes it (iteration 7).
			if ( last.Impostor.SeeThrough > SeeThroughShare && level != FlatpackQuality.Ultra )
			{
				i = levels.Length - 2;
				continue;
			}

			// Holds up from the target (judged out to 16 radii; past that every level matches as well as it ever will).
			if ( last.Measured.HoldsUpFrom <= MathF.Min( settings.AutoTargetRadii, 16.0f ) )
				return Finish( last, timer );

			var score = last.Measured.HoldsUpFrom;

			if ( score < bestScore )
			{
				bestScore = score;
				best = level;
			}
		}

		// Nothing held up (fine wire mesh, for example). Keep whichever level held up from closest.
		if ( best != FlatpackQuality.Ultra )
		{
			var baked = await BakeLevelAsync( model, settings.WithQuality( best ), progress, cancel );
			last = await MeasureAsync( baked, progress, cancel );
		}

		return Finish( last, timer );
	}

	static Result Finish( Result r, Stopwatch timer ) => new()
	{
		Impostor = r.Impostor, ImpostorPath = r.ImpostorPath, MaterialPath = r.MaterialPath, Files = r.Files,
		Level = r.Level, Measured = r.Measured, Duration = timer.Elapsed,
	};

	/// <summary>
	/// Measure a finished bake and store where it holds up on its <c>.fpack</c>.
	/// </summary>
	static async Task<Result> MeasureAsync( Result baked, Action<float, string> progress, CancellationToken cancel )
	{
		if ( baked.Impostor.SeeThrough > SeeThroughShare )
		{
			progress?.Invoke( 0.0f, "Fitting see-through coverage to the model" );
			var (soft, growth) = await FlatpackQualityCheck.FitSoftCoverageAsync( baked.Impostor, cancel );
			WriteMaterialValue( baked.MaterialPath, "g_flFlatpackSoftCoverage", soft );
			WriteMaterialValue( baked.MaterialPath, "g_flFlatpackSoftGrowth", growth );
		}

		progress?.Invoke( 0.0f, "Measuring against the model" );
		var report = await FlatpackQualityCheck.MeasureAsync( baked.Impostor, cancel: cancel );

		var impostor = baked.Impostor;
		impostor.HoldsUpFrom = float.IsPositiveInfinity( report.HoldsUpFrom ) ? -1.0f : report.HoldsUpFrom;
		AssetSystem.FindByPath( baked.ImpostorPath )?.SaveToDisk( impostor );
		progress?.Invoke( 1.0f, report.Summary );

		return new Result
		{
			Impostor = impostor, ImpostorPath = baked.ImpostorPath, MaterialPath = baked.MaterialPath, Files = baked.Files,
			Duration = baked.Duration, Level = baked.Level, Measured = report,
		};
	}

	/// <summary>
	/// Share of covered texels (alpha above 2 of 255) that are only partly covered (below 250).
	/// </summary>
	static float PartialCoverageShare( Color32[] albedo )
	{
		long covered = 0, partial = 0;
		foreach ( var c in albedo )
		{
			if ( c.a <= 2 ) continue;
			covered++;
			if ( c.a < 250 ) partial++;
		}
		return covered > 0 ? partial / (float)covered : 0.0f;
	}

	/// <summary>
	/// Set one value in a generated <c>.vmat</c> (adding the line if it's missing) and recompile it.
	/// </summary>
	static void WriteMaterialValue( string materialPath, string key, float value )
	{
		var asset = AssetSystem.FindByPath( materialPath );
		if ( asset is null || !File.Exists( asset.AbsolutePath ) )
			return;

		var line = $"\t{key} \"{value.ToString( "0.000000", CultureInfo.InvariantCulture )}\"";
		var lines = File.ReadAllLines( asset.AbsolutePath ).ToList();
		var index = lines.FindIndex( l => l.TrimStart().StartsWith( key + " " ) );
		if ( index >= 0 )
			lines[index] = line;
		else
			lines.Insert( Math.Max( 0, lines.FindLastIndex( l => l.Trim() == "}" ) ), line );

		File.WriteAllLines( asset.AbsolutePath, lines );
		asset.Compile( true );
	}

	/// <summary>
	/// Bake one model at one quality level. Runs on the main thread but yields between frames, so the editor keeps
	/// drawing; cancel with <paramref name="cancel"/>. <paramref name="progress"/> gets a 0..1 fraction and a status line.
	/// </summary>
	static async Task<Result> BakeLevelAsync( Model model, FlatpackBakeSettings settings, Action<float, string> progress, CancellationToken cancel )
	{
		if ( model is null || model.IsError )
			throw new ArgumentException( "Pick a valid model to bake." );

		var timer = Stopwatch.StartNew();
		var (grid, atlasSize, searchSteps) = settings.Resolve();
		var n = Math.Clamp( grid, 2, 32 );
		// The shader addresses frame (x, y) at (x + u) / n of the atlas, so the atlas must be exactly n frames wide.
		// 2048 / 12 isn't whole; round the frame down (to keep the atlas a multiple of 4 for block compression) and
		// shrink the atlas to fit, rather than let the frames drift out of place across the atlas.
		var cell = (int)atlasSize / n;
		while ( (cell * n) % 4 != 0 )
			cell--;
		var atlas = cell * n;
		var layout = settings.Layout;

		var bounds = model.RenderBounds;
		if ( bounds.Size.Length < 0.001f )
			bounds = model.Bounds;

		// A sphere fitted to the vertices is usually much smaller than the box's half diagonal, so the object fills more
		// of every frame. Fall back to the box for models without readable vertices.
		var vertices = model.GetVertices();
		var points = vertices is { Length: > 0 } ? vertices.Select( v => v.Position ).ToArray() : null;
		var (center, radius) = points is not null
			? FlatpackMath.BoundingSphere( points )
			: FlatpackMath.BoundingSphere( bounds );

		var (folder, assetsRoot) = ResolveOutputFolder( model, settings );
		var name = SafeName( model );

		var fitted = settings.FitEachView && points is not null;
		var fits = FitFrames( points, center, radius, n, cell, layout, fitted );

		var albedo = new Color32[atlas * atlas];
		var normal = new Color32[atlas * atlas];
		var depth = new float[atlas * atlas];
		var rao = settings.BakeRoughnessAo ? new Color32[atlas * atlas] : null;
		var supersample = Math.Clamp( settings.Supersample, 1, 4 );

		progress?.Invoke( 0.0f, $"Baking {name}" );
		var bakedMetal = await CaptureFramesAsync( model, layout, n, cell, atlas, center, radius, fits, albedo, normal, depth, rao, settings.BakeSelfShadow, supersample, progress, cancel );
		var seeThrough = PartialCoverageShare( albedo );

		// Fine wire mesh does worse framed: a fence panel seen edge-on squeezes into a sliver of texels, and its card came
		// out sparser than the real fence from 16 radii (iteration 8: 64 to 68% wrong pixels against 45 unframed). Bake
		// those views sphere-sized again. The weed tree's twigs (0.42 framed) keep the framing, which helped them a lot.
		if ( fitted && seeThrough > FineMeshShare )
		{
			progress?.Invoke( 0.0f, $"Baking {name} again, unframed (fine mesh)" );
			fits = FitFrames( points, center, radius, n, cell, layout, false );
			bakedMetal = await CaptureFramesAsync( model, layout, n, cell, atlas, center, radius, fits, albedo, normal, depth, rao, settings.BakeSelfShadow, supersample, progress, cancel );
			seeThrough = PartialCoverageShare( albedo );
		}

		cancel.ThrowIfCancellationRequested();
		progress?.Invoke( 0.85f, "Padding edges" );
		await Task.Delay( 1 );

		DilateIntoFrames( albedo, normal, depth, rao, atlas, cell, settings.EdgePadding );
		var mipAlphaScales = MipAlphaScales( albedo, atlas );

		cancel.ThrowIfCancellationRequested();
		progress?.Invoke( 0.9f, "Writing files" );
		await Task.Delay( 1 );

		Directory.CreateDirectory( folder );
		var files = new List<string>();

		var depthBits = settings.DepthBits >= 12 ? 16 : 8;
		var albedoFile = WritePng( Path.Combine( folder, $"{name}_fp_albedo.png" ), albedo, atlas, files );
		var normalFile = WritePng( Path.Combine( folder, $"{name}_fp_normal.png" ), normal, atlas, files );
		var depthFile = depthBits == 16
			? WritePfm( Path.Combine( folder, $"{name}_fp_depth.pfm" ), depth, atlas, files )
			: WritePng( Path.Combine( folder, $"{name}_fp_depth.png" ), DepthBytes( depth ), atlas, files );
		var framesFile = WriteFrames( Path.Combine( folder, $"{name}_fp_frames.png" ), fits, n, files );

		// The coarse map must never read deeper than the depth texture it summarises, or the search would skip a block
		// it should have searched: BC4 rounds by a few steps, R16 by half of one.
		var coarse = CoarseDepth( depth, atlas, cell, n, depthBits == 16 ? 1.0f / 65535.0f : 4.0f / 255.0f, out var coarseSize );
		var coarseFile = Path.Combine( folder, $"{name}_fp_depthmin.pfm" );
		FlatpackPfm.WriteGray( coarseFile, coarse, coarseSize, coarseSize );
		files.Add( coarseFile );
		var raoFile = rao is null ? null : WritePng( Path.Combine( folder, $"{name}_fp_rao.png" ), rao, atlas, files );

		var zLocked = settings.ZLockedCard || layout == FlatpackLayout.Cylinder;
		var materialFile = Path.Combine( folder, $"{name}_fp.vmat" );
		File.WriteAllText( materialFile, BuildMaterial(
			Relative( assetsRoot, albedoFile ), Relative( assetsRoot, normalFile ), Relative( assetsRoot, depthFile ), depthBits,
			Relative( assetsRoot, framesFile ), Relative( assetsRoot, coarseFile ), raoFile is null ? null : Relative( assetsRoot, raoFile ),
			n, layout, center, radius, zLocked, mipAlphaScales, searchSteps, seeThrough > SeeThroughShare, bakedMetal ) );
		files.Add( materialFile );

		progress?.Invoke( 0.95f, "Compiling material" );

		// Textures first, so the material compile finds them already built.
		foreach ( var file in files )
		{
			var asset = AssetSystem.RegisterFile( file );
			if ( asset is not null )
				await asset.CompileIfNeededAsync( 120.0f );
		}

		var materialPath = Relative( assetsRoot, materialFile );
		var material = Material.Load( materialPath );

		// The resource: create it once, then overwrite its values on re-bakes so references to it survive.
		var impostorFile = Path.Combine( folder, $"{name}.fpack" );
		var impostorPath = Relative( assetsRoot, impostorFile );
		var impostorAsset = AssetSystem.FindByPath( impostorPath );
		if ( impostorAsset is null )
		{
			impostorAsset = AssetSystem.CreateResource( "fpack", impostorFile );
			await impostorAsset.CompileIfNeededAsync();
		}

		if ( !impostorAsset.TryLoadResource<FlatpackImpostor>( out var impostor ) )
			throw new InvalidOperationException( $"Couldn't create {impostorPath}." );

		impostor.SourceModel = model;
		impostor.Material = material;
		impostor.Layout = layout;
		impostor.GridSize = n;
		impostor.AtlasSize = atlas;
		impostor.Center = center;
		impostor.Radius = radius;
		impostor.Bounds = bounds;
		impostor.ThinDetail = mipAlphaScales[1];
		impostor.QualityLevel = settings.Quality.ToString();
		impostor.HoldsUpFrom = 0.0f;
		impostor.SeeThrough = seeThrough;
		impostorAsset.SaveToDisk( impostor );
		files.Add( impostorFile );

		progress?.Invoke( 1.0f, $"Baked {name}" );

		return new Result
		{
			Impostor = impostor,
			ImpostorPath = impostorPath,
			MaterialPath = materialPath,
			Files = files,
			Duration = timer.Elapsed,
			Level = settings.Quality,
		};
	}

	/// <summary>
	/// Each view's bounds: its outline and depth range from the vertices when <paramref name="fitted"/>, else the whole
	/// sphere. Padded by two texels so bilinear filtering at the outline stays inside the frame.
	/// </summary>
	static FlatpackMath.FrameFit[] FitFrames( Vector3[] points, Vector3 center, float radius, int n, int cell, FlatpackLayout layout, bool fitted )
	{
		var fits = new FlatpackMath.FrameFit[n * n];
		for ( var y = 0; y < n; y++ )
		{
			for ( var x = 0; x < n; x++ )
			{
				fits[y * n + x] = fitted
					? FlatpackMath.FitFrame( points, center, radius, FlatpackMath.FrameDirection( x, y, n, layout ), cell, 2 )
					: FlatpackMath.DecodeFrameFit( FlatpackMath.EncodeFrameFit( FlatpackMath.FrameFit.Sphere ) );
			}
		}
		return fits;
	}

	/// <summary>
	/// Render every frame and copy it into the CPU atlases. Returns whether metalness went into the roughness/AO atlas.
	/// </summary>
	static async Task<bool> CaptureFramesAsync( Model model, FlatpackLayout layout, int n, int cell, int atlas, Vector3 center, float radius, FlatpackMath.FrameFit[] fits,
		Color32[] albedo, Color32[] normal, float[] depth, Color32[] rao, bool selfShadow, int supersample, Action<float, string> progress, CancellationToken cancel )
	{
		var depthMaterial = Material.Create( "flatpack_bake_depth", DepthShader, true );
		selfShadow &= rao is not null;

		var scene = Scene.CreateEditorScene();
		scene.Name = "Flatpack Bake";

		try
		{
			ModelRenderer renderer;
			CameraComponent camera;
			DirectionalLight sun = null;
			MetalPass metal = null;

			using ( scene.Push() )
			{
				var source = new GameObject( true, "source" );
				renderer = source.Components.Create<ModelRenderer>();
				renderer.Model = model;

				// Bake from the most detailed LOD, whatever the frame's screen size would pick.
				renderer.LodOverride = 0;

				var eye = new GameObject( true, "camera" );
				camera = eye.Components.Create<CameraComponent>();
				camera.BackgroundColor = Color.Transparent;
				camera.Orthographic = true;

				if ( selfShadow )
				{
					var light = new GameObject( true, "sky light" );
					sun = light.Components.Create<DirectionalLight>();
					sun.LightColor = Color.White;
					sun.Shadows = true;
				}

				if ( rao is not null )
					metal = MetalPass.Create( renderer );
			}

			var frame = new Bitmap( cell * supersample, cell * supersample, false );
			var total = n * n;
			var sliceTimer = Stopwatch.StartNew();

			for ( var y = 0; y < n; y++ )
			{
				for ( var x = 0; x < n; x++ )
				{
					cancel.ThrowIfCancellationRequested();

					var dir = FlatpackMath.FrameDirection( x, y, n, layout );
					var fit = fits[y * n + x];

					Color32[] colorPixels, normalPixels, roughPixels = null, aoPixels = null;
					float[] depthPixels;
					byte[] visibility = null, metalness = null;

					using ( scene.Push() )
					{
						FrameCamera( camera, center, radius, dir, fit );
						scene.EditorTick( 0.0f, 0.0f );

						colorPixels = Capture( camera, renderer, frame, SceneCameraDebugMode.Albedo, null );
						normalPixels = Capture( camera, renderer, frame, SceneCameraDebugMode.NormalMap, null );
						depthPixels = DecodeDepth( Capture( camera, renderer, frame, SceneCameraDebugMode.Albedo, depthMaterial ) );

						if ( rao is not null )
						{
							roughPixels = Capture( camera, renderer, frame, SceneCameraDebugMode.Roughness, null );
							aoPixels = Capture( camera, renderer, frame, SceneCameraDebugMode.AmbientOcclusion, null );
						}

						if ( selfShadow )
							visibility = CaptureSkyVisibility( scene, camera, renderer, sun, frame, normalPixels );

						if ( metal is not null )
						{
							if ( sun is not null ) sun.Enabled = false;
							metalness = metal.Capture( scene, camera, renderer, frame );
							if ( sun is not null ) sun.Enabled = true;
						}
					}

					if ( supersample > 1 )
						Downsample( cell, supersample, ref colorPixels, ref normalPixels, ref depthPixels, ref roughPixels, ref aoPixels, ref visibility, ref metalness );

					BlitFrame( x, y, cell, atlas, colorPixels, normalPixels, depthPixels, roughPixels, aoPixels, visibility, metalness, albedo, normal, depth, rao );

					var done = y * n + x + 1;
					progress?.Invoke( done / (float)total * 0.85f, $"Frame {done} of {total}" );

					// Hand the main thread back now and then so the editor stays responsive.
					if ( sliceTimer.ElapsedMilliseconds > 30 )
					{
						await Task.Delay( 1 );
						sliceTimer.Restart();
					}
				}
			}

			return metal is not null;
		}
		finally
		{
			scene.Destroy();
		}
	}

	/// <summary>
	/// Directions the sky-visibility pass lights the model from: one overhead and two rings, spread over the upper
	/// hemisphere. Fixed in object space, so every frame sees the same shadows and blending between frames stays seamless.
	/// </summary>
	static readonly Angles[] SkyLights =
	{
		new( 85, 0, 0 ),
		new( 60, 0, 0 ), new( 60, 120, 0 ), new( 60, 240, 0 ),
		new( 30, 45, 0 ), new( 30, 135, 0 ), new( 30, 225, 0 ), new( 30, 315, 0 ),
	};

	/// <summary>
	/// How much of the sky each texel sees, 0..255: diffuse light with shadows over diffuse light without, both summed over
	/// <see cref="SkyLights"/>. This is the self-shadowing a dense model (a tree's inner branches) has and a card can't
	/// work out live; the shader uses it to darken ambient light and part of the albedo.
	/// </summary>
	/// <remarks>
	/// Eight lights average out to soft occlusion; with only a few, a surface facing one light gets that light's hard
	/// shadow and turns black. Ratios are taken in linear light.
	/// </remarks>
	static byte[] CaptureSkyVisibility( Scene scene, CameraComponent camera, ModelRenderer renderer, DirectionalLight sun, Bitmap target, Color32[] normals )
	{
		var count = target.Width * target.Height;
		var lit = new float[count];
		var open = new float[count];

		foreach ( var angles in SkyLights )
		{
			sun.WorldRotation = angles;

			foreach ( var shadowed in new[] { true, false } )
			{
				sun.Shadows = shadowed;
				scene.EditorTick( 0.0f, 0.0f );

				// Render twice: the first render after moving or toggling the light can still use the old shadow map.
				Capture( camera, renderer, target, SceneCameraDebugMode.Diffuse, null );
				var pixels = Capture( camera, renderer, target, SceneCameraDebugMode.Diffuse, null );

				var sum = shadowed ? lit : open;
				for ( var i = 0; i < count; i++ )
					sum[i] += Linear( pixels[i].r );
			}
		}

		// Shadow-map bias and terminator softness keep even fully open texels a little under 1, and that shifts from frame
		// to frame. Normalise: the well-lit texels of this frame (95th percentile) count as fully visible. Capped, so a
		// frame that really is mostly in shadow isn't brightened.
		var ratio = new float[count];
		var samples = new List<float>( count );
		var openMax = 0.0f;
		for ( var i = 0; i < count; i++ )
			openMax = MathF.Max( openMax, open[i] );

		for ( var i = 0; i < count; i++ )
		{
			ratio[i] = open[i] > 0.0f ? Math.Clamp( lit[i] / open[i], 0.0f, 1.0f ) : 1.0f;
			if ( normals[i].a > 0 && open[i] > openMax * 0.2f )
				samples.Add( ratio[i] );
		}

		samples.Sort();
		var reference = samples.Count > 16 ? samples[(int)(samples.Count * 0.95f)] : 1.0f;
		reference = Math.Clamp( reference, 0.75f, 1.0f );

		var visibility = new byte[count];
		for ( var i = 0; i < count; i++ )
		{
			// Texels the sky barely reaches (undersides) carry little information; fade them toward fully visible.
			var known = openMax > 0.0f ? Math.Clamp( open[i] / (openMax * 0.2f), 0.0f, 1.0f ) : 0.0f;
			var v = Math.Clamp( ratio[i] / reference, 0.0f, 1.0f );
			visibility[i] = (byte)Math.Clamp( (int)MathF.Round( (v * known + (1.0f - known)) * 255.0f ), 0, 255 );
		}

		return visibility;
	}

	/// <summary>
	/// The diffuse lighting view writes light through an sRGB target; ratios only make sense in linear light.
	/// </summary>
	static float Linear( byte value )
	{
		var c = value / 255.0f;
		return c <= 0.04045f ? c / 12.92f : MathF.Pow( (c + 0.055f) / 1.055f, 2.4f );
	}

	/// <summary>
	/// Point the bake camera at one frame: orthographic, framing exactly the frame's fitted image rectangle (wider or
	/// taller than square when the object is), with the near plane on its nearest surface.
	/// </summary>
	static void FrameCamera( CameraComponent camera, Vector3 center, float radius, Vector3 dir, FlatpackMath.FrameFit fit )
	{
		FlatpackMath.FrameBasis( dir, out var right, out var up );

		var size = (fit.Max - fit.Min) * (2.0f * radius);
		var mid = (fit.Min + fit.Max) * 0.5f - new Vector2( 0.5f, 0.5f );

		// Image v runs down, so the rectangle's centre is up by -v. The camera stays two radii out, outside the sphere.
		var distance = radius * 2.0f;
		camera.WorldPosition = center + right * (mid.x * 2.0f * radius) - up * (mid.y * 2.0f * radius) + dir * distance;
		camera.WorldRotation = FlatpackMath.FrameRotation( dir );

		// Depth 0..1 spans one bounding diameter back from the nearest surface, not just to the furthest one: the background
		// then sits at least a diameter behind the object, where the shader's search never reaches, even in mips that
		// average an outline with it. 16 bits over a diameter is still 256 times finer than the 8 bits it replaces.
		var zNear = distance - fit.Near * radius;
		var zFar = zNear + 2.0f * radius;
		camera.OrthographicHeight = size.y;
		camera.ZNear = zNear;
		camera.ZFar = zFar;

		// The engine's own orthographic camera is square for a square target; this is the same matrix (checked against
		// CameraComponent.ProjectionMatrix in the editor) with its own width, so a tall object fills the frame both ways.
		camera.CustomProjectionMatrix = new Matrix(
			2.0f / size.x, 0, 0, 0,
			0, 2.0f / size.y, 0, 0,
			0, 0, -1.0f / (zFar - zNear), -zNear / (zFar - zNear),
			0, 0, 0, 1 );
	}

	/// <summary>
	/// The depth bake's red and green bytes back to one 0..1 depth: red is whole 1/255 steps, green the fraction of a step.
	/// </summary>
	static float[] DecodeDepth( Color32[] pixels )
	{
		var depth = new float[pixels.Length];
		for ( var i = 0; i < pixels.Length; i++ )
			depth[i] = Math.Clamp( (pixels[i].r + pixels[i].g / 255.0f) / 255.0f, 0.0f, 1.0f );
		return depth;
	}

	static Color32[] Capture( CameraComponent camera, ModelRenderer renderer, Bitmap target, SceneCameraDebugMode mode, Material overrideMaterial )
	{
		camera.DebugMode = mode;
		renderer.MaterialOverride = overrideMaterial;
		camera.RenderToBitmap( target );
		return target.GetPixels32();
	}

	/// <summary>
	/// Bakes metalness. s&amp;box has no metalness debug view and materials store it in different ways (a texture, a
	/// constant, packed channels), so this solves for it instead of reading it: the Reflect view of the real materials
	/// against copies forced to metalness 0 and 1, all lit by one light from the camera. Per texel,
	/// metal = (real - dielectric) / (metal - dielectric).
	/// </summary>
	sealed class MetalPass
	{
		Material[] _original, _dielectric, _metal;
		DirectionalLight _light;

		/// <summary>A pass for <paramref name="renderer"/>'s model, or null when none of its materials can be metal.</summary>
		public static MetalPass Create( ModelRenderer renderer )
		{
			var count = renderer.Materials.Count;
			var pass = new MetalPass { _original = new Material[count], _dielectric = new Material[count], _metal = new Material[count] };
			var any = false;
			for ( var i = 0; i < count; i++ )
			{
				var source = renderer.Materials.GetOriginal( i );
				pass._original[i] = source;
				if ( source is null )
					continue;

				any |= source.GetFeature( "F_METALNESS_TEXTURE" ) != 0 || source.GetVector4( "g_flMetalness" ).x > 0.0f;
				pass._dielectric[i] = Forced( source, 0.0f, i );
				pass._metal[i] = Forced( source, 1.0f, i );
			}

			if ( !any )
				return null;

			pass._light = new GameObject( true, "metal light" ).Components.Create<DirectionalLight>();
			pass._light.LightColor = Color.White;
			pass._light.Shadows = false;
			pass._light.Enabled = false;
			return pass;
		}

		static Material Forced( Material source, float metalness, int slot )
		{
			var copy = source.CreateCopy( $"flatpack_metal_{metalness:0}_{slot}" );
			copy.SetFeature( "F_METALNESS_TEXTURE", 0 );
			copy.Set( "g_flMetalness", metalness );
			return copy;
		}

		/// <summary>Metalness per pixel of <paramref name="target"/>, 0..255. Call inside the bake scene.</summary>
		public byte[] Capture( Scene scene, CameraComponent camera, ModelRenderer renderer, Bitmap target )
		{
			_light.WorldRotation = camera.WorldRotation;
			_light.Enabled = true;
			var real = Render( scene, camera, renderer, target, _original );
			var low = Render( scene, camera, renderer, target, _dielectric );
			var high = Render( scene, camera, renderer, target, _metal );
			_light.Enabled = false;
			renderer.ClearMaterialOverrides();

			var w = target.Width;
			var h = target.Height;
			var count = real.Length;
			var value = new float[count];
			var known = new bool[count];
			for ( var i = 0; i < count; i++ )
			{
				if ( real[i].a < 128 )
					continue;

				float r = Brightness( real[i] ), d = Brightness( low[i] ), m = Brightness( high[i] );

				// Where metal and dielectric reflect alike (dark colours, grazing angles) the solve has nothing to go on;
				// metalness barely changes the look there anyway. Those texels borrow their neighbours' value below.
				if ( MathF.Abs( m - d ) < 6.0f )
					continue;

				value[i] = Math.Clamp( (r - d) / (m - d), 0.0f, 1.0f );
				known[i] = true;
			}

			// A few rounds of filling unknown texels from known neighbours; anything still unknown is non-metal.
			for ( var round = 0; round < 6; round++ )
			{
				var next = (bool[])known.Clone();
				for ( var i = 0; i < count; i++ )
				{
					if ( known[i] || real[i].a < 128 )
						continue;

					int x = i % w, y = i / w, n = 0;
					var sum = 0.0f;
					for ( var dy = -1; dy <= 1; dy++ )
					{
						for ( var dx = -1; dx <= 1; dx++ )
						{
							int nx = x + dx, ny = y + dy;
							if ( nx < 0 || ny < 0 || nx >= w || ny >= h ) continue;
							var j = ny * w + nx;
							if ( !known[j] ) continue;
							sum += value[j];
							n++;
						}
					}

					if ( n == 0 )
						continue;

					value[i] = sum / n;
					next[i] = true;
				}
				known = next;
			}

			var bytes = new byte[count];
			for ( var i = 0; i < count; i++ )
				bytes[i] = (byte)Math.Clamp( (int)MathF.Round( value[i] * 255.0f ), 0, 255 );
			return bytes;
		}

		static Color32[] Render( Scene scene, CameraComponent camera, ModelRenderer renderer, Bitmap target, Material[] materials )
		{
			for ( var i = 0; i < materials.Length; i++ )
			{
				if ( materials[i] is not null )
					renderer.Materials.SetOverride( i, materials[i] );
			}
			renderer.Materials.Apply();
			scene.EditorTick( 0.0f, 0.0f );
			return FlatpackBaker.Capture( camera, renderer, target, SceneCameraDebugMode.Reflect, null );
		}

		static float Brightness( Color32 c ) => (c.r + c.g + c.b) / 3.0f;
	}

	/// <summary>
	/// Average a supersampled frame down to <paramref name="cell"/> texels a side. Alpha becomes the share of covered
	/// samples; colour, normal, depth and the material channels average over the covered samples only, so the empty
	/// background doesn't darken thin parts.
	/// </summary>
	static void Downsample( int cell, int ss, ref Color32[] color, ref Color32[] normal, ref float[] depth, ref Color32[] rough, ref Color32[] ao, ref byte[] visibility, ref byte[] metal )
	{
		var size = cell * ss;
		var outColor = new Color32[cell * cell];
		var outNormal = new Color32[cell * cell];
		var outDepth = new float[cell * cell];
		var outRough = rough is null ? null : new Color32[cell * cell];
		var outAo = ao is null ? null : new Color32[cell * cell];
		var outVis = visibility is null ? null : new byte[cell * cell];
		var outMetal = metal is null ? null : new byte[cell * cell];

		for ( var y = 0; y < cell; y++ )
		{
			for ( var x = 0; x < cell; x++ )
			{
				float r = 0, g = 0, b = 0, a = 0, nx = 0, ny = 0, nz = 0, d = 0, ro = 0, oc = 0, vi = 0, me = 0;
				var covered = 0;

				for ( var sy = 0; sy < ss; sy++ )
				{
					for ( var sx = 0; sx < ss; sx++ )
					{
						var i = (y * ss + sy) * size + x * ss + sx;
						var c = color[i];

						// The albedo view writes the material's raw opacity. Alpha-tested textures keep their small mips
						// above the cut on purpose, so averaging that raw value overstates thin detail (a fence panel that's
						// 12% wire read as 44%). Cut each sample the way the game cuts the real model, then average.
						if ( c.a < AlphaTestCut )
							continue;

						a += 255;
						covered++;
						r += c.r; g += c.g; b += c.b;
						nx += normal[i].r / 127.5f - 1.0f; ny += normal[i].g / 127.5f - 1.0f; nz += normal[i].b / 127.5f - 1.0f;
						d += depth[i];
						if ( rough is not null ) ro += rough[i].r;
						if ( ao is not null ) oc += ao[i].r;
						if ( visibility is not null ) vi += visibility[i];
						if ( metal is not null ) me += metal[i];
					}
				}

				var o = y * cell + x;
				if ( covered == 0 )
					continue;

				byte B( float v ) => (byte)Math.Clamp( (int)MathF.Round( v ), 0, 255 );
				var alpha = B( a / (ss * ss) );
				outColor[o] = new Color32( B( r / covered ), B( g / covered ), B( b / covered ), Math.Max( alpha, (byte)1 ) );

				var len = MathF.Sqrt( nx * nx + ny * ny + nz * nz );
				if ( len < 1e-4f ) len = 1.0f;
				outNormal[o] = new Color32( B( (nx / len + 1.0f) * 127.5f ), B( (ny / len + 1.0f) * 127.5f ), B( (nz / len + 1.0f) * 127.5f ), 255 );

				outDepth[o] = d / covered;
				if ( outRough is not null ) { var v = B( ro / covered ); outRough[o] = new Color32( v, v, v, 255 ); }
				if ( outAo is not null ) { var v = B( oc / covered ); outAo[o] = new Color32( v, v, v, 255 ); }
				if ( outVis is not null ) outVis[o] = B( vi / covered );
				if ( outMetal is not null ) outMetal[o] = B( me / covered );
			}
		}

		color = outColor; normal = outNormal; depth = outDepth; rough = outRough; ao = outAo; visibility = outVis; metal = outMetal;
	}

	/// <summary>
	/// Copy one frame's passes into the atlases. Uncovered texels stay fully clear (depth at the far plane) for the
	/// dilation pass.
	/// </summary>
	static void BlitFrame( int fx, int fy, int cell, int atlas, Color32[] color, Color32[] normal, float[] depth, Color32[] rough, Color32[] ao,
		byte[] visibility, byte[] metal, Color32[] albedoAtlas, Color32[] normalAtlas, float[] depthAtlas, Color32[] raoAtlas )
	{
		for ( var py = 0; py < cell; py++ )
		{
			var row = (fy * cell + py) * atlas + fx * cell;

			for ( var px = 0; px < cell; px++ )
			{
				var src = py * cell + px;
				var dst = row + px;
				var c = color[src];

				if ( c.a == 0 )
				{
					albedoAtlas[dst] = default;
					normalAtlas[dst] = new Color32( 128, 128, 255, 255 );
					depthAtlas[dst] = 1.0f;
					if ( raoAtlas is not null ) raoAtlas[dst] = default;
					continue;
				}

				albedoAtlas[dst] = c;
				normalAtlas[dst] = new Color32( normal[src].r, normal[src].g, normal[src].b, 255 );
				depthAtlas[dst] = depth[src];

				// Metalness rides in alpha as 1 + metal x 254 (never 0, which would wipe the colour; see the shader).
				if ( raoAtlas is not null )
					raoAtlas[dst] = new Color32( rough[src].r, ao[src].r, visibility?[src] ?? 255, metal is null ? (byte)255 : (byte)(1 + metal[src] * 254 / 255) );
			}
		}
	}

	/// <summary>
	/// Grow every frame's colours outward from its silhouette, staying inside the frame, so bilinear filtering and
	/// mips near the edge read the object's own colour instead of the empty background.
	/// </summary>
	/// <remarks>
	/// One multi-source breadth-first fill over the whole atlas: each empty texel takes the values of the nearest
	/// covered texel (by steps) in its own frame. Albedo alpha on grown texels is 1/255, far below the alpha clip,
	/// but non-zero so the colour survives the PNG round trip.
	/// </remarks>
	static void DilateIntoFrames( Color32[] albedo, Color32[] normal, float[] depth, Color32[] rao, int atlas, int cell, int padding )
	{
		var maxSteps = padding <= 0 ? int.MaxValue : padding;
		var source = new int[albedo.Length];
		var steps = new int[albedo.Length];
		var queue = new Queue<int>( albedo.Length / 4 );

		for ( var i = 0; i < albedo.Length; i++ )
		{
			if ( albedo[i].a > 0 )
			{
				source[i] = i;
				queue.Enqueue( i );
			}
			else
			{
				source[i] = -1;
			}
		}

		while ( queue.Count > 0 )
		{
			var i = queue.Dequeue();
			if ( steps[i] >= maxSteps )
				continue;

			var x = i % atlas;
			var y = i / atlas;

			// Neighbours in the same frame only, never across a frame border.
			if ( x % cell > 0 ) Grow( i, i - 1 );
			if ( x % cell < cell - 1 ) Grow( i, i + 1 );
			if ( y % cell > 0 ) Grow( i, i - atlas );
			if ( y % cell < cell - 1 ) Grow( i, i + atlas );
		}

		void Grow( int from, int to )
		{
			if ( source[to] >= 0 )
				return;

			var s = source[from];
			source[to] = s;
			steps[to] = steps[from] + 1;
			queue.Enqueue( to );

			var c = albedo[s];
			albedo[to] = new Color32( c.r, c.g, c.b, 1 );

			// Grown texels keep the neighbour's normal but sit at the far plane, so the shader's depth search passes
			// straight through the background instead of stopping on borrowed depth and drawing a ghost.
			normal[to] = normal[s];
			depth[to] = 1.0f;
			if ( rao is not null ) rao[to] = rao[s];
		}
	}

	static string WritePng( string path, Color32[] pixels, int size, List<string> files ) => WritePng( path, pixels, size, size, files );

	static string WritePng( string path, Color32[] pixels, int width, int height, List<string> files )
	{
		using var bitmap = new Bitmap( width, height, false );
		var colors = new Color[pixels.Length];
		for ( var i = 0; i < pixels.Length; i++ )
			colors[i] = pixels[i];

		bitmap.SetPixels( colors );
		File.WriteAllBytes( path, bitmap.ToPng() );
		files.Add( path );
		return path;
	}

	/// <summary>The 16-bit depth atlas, as a float image the texture compiler turns into R16.</summary>
	static string WritePfm( string path, float[] depth, int size, List<string> files )
	{
		FlatpackPfm.WriteGray( path, depth, size, size );
		files.Add( path );
		return path;
	}

	/// <summary>Depth rounded to bytes, for the 8-bit (BC4) depth texture.</summary>
	static Color32[] DepthBytes( float[] depth )
	{
		var bytes = new Color32[depth.Length];
		for ( var i = 0; i < depth.Length; i++ )
		{
			var v = (byte)Math.Clamp( (int)MathF.Round( depth[i] * 255.0f ), 0, 255 );
			bytes[i] = new Color32( v, v, v, 255 );
		}
		return bytes;
	}

	/// <summary>Texels per side of one coarse depth block (written into the material as <c>g_flFlatpackCoarseBlock</c>).</summary>
	public const int CoarseBlock = 16;

	/// <summary>
	/// The coarse depth map: for each <see cref="CoarseBlock"/> x <see cref="CoarseBlock"/> texel block of each frame, the
	/// nearest depth in it (the smallest value) less <paramref name="margin"/>, laid out per frame like the atlas
	/// (frame x, y at blocks x, y of a <paramref name="size"/>-wide square). The shader's search skips every block whose
	/// nearest surface the ray never gets down to, and marches the rest a texel at a time.
	/// </summary>
	static float[] CoarseDepth( float[] depth, int atlas, int cell, int n, float margin, out int size )
	{
		var blocks = (cell + CoarseBlock - 1) / CoarseBlock;
		size = blocks * n;
		var coarse = new float[size * size];

		for ( var fy = 0; fy < n; fy++ )
		for ( var fx = 0; fx < n; fx++ )
		for ( var by = 0; by < blocks; by++ )
		for ( var bx = 0; bx < blocks; bx++ )
		{
			var nearest = 1.0f;
			for ( var y = by * CoarseBlock; y < Math.Min( (by + 1) * CoarseBlock, cell ); y++ )
			{
				var row = (fy * cell + y) * atlas + fx * cell;
				for ( var x = bx * CoarseBlock; x < Math.Min( (bx + 1) * CoarseBlock, cell ); x++ )
					nearest = MathF.Min( nearest, depth[row + x] );
			}

			coarse[(fy * blocks + by) * size + fx * blocks + bx] = MathF.Max( 0.0f, nearest - margin );
		}

		return coarse;
	}

	/// <summary>
	/// The frame lookup texture: two texels per frame (see <see cref="FlatpackMath.EncodeFrameFit"/>), image rectangles in
	/// the left half, depth ranges <see cref="FlatpackMath.FrameFitColumns"/> texels to the right.
	/// </summary>
	static string WriteFrames( string path, FlatpackMath.FrameFit[] fits, int n, List<string> files )
	{
		const int width = FlatpackMath.FrameFitColumns * 2;
		const int height = FlatpackMath.FrameFitColumns;
		var pixels = new Color32[width * height];
		for ( var i = 0; i < pixels.Length; i++ )
			pixels[i] = new Color32( 0, 0, 255, 255 );

		for ( var y = 0; y < n; y++ )
		{
			for ( var x = 0; x < n; x++ )
			{
				var (rect, range) = FlatpackMath.EncodeFrameFit( fits[y * n + x] );
				pixels[y * width + x] = rect;
				pixels[y * width + x + FlatpackMath.FrameFitColumns] = range;
			}
		}

		return WritePng( path, pixels, width, height, files );
	}

	/// <summary>
	/// Per-mip alpha multipliers that keep the share of covered texels the same as at full resolution.
	/// </summary>
	/// <remarks>
	/// Mipmaps average a thin part's alpha with the empty space around it. Past a mip or two, spindles, wires and leaf
	/// tips fall below the 0.5 clip and the card loses them (or they sparkle). For each mip level we find the alpha
	/// that the same share of texels exceeds as were above 0.5 at full size, and store the factor that lifts it back to
	/// 0.5. A fixed boost instead over-fills foliage and fences into solid blobs. Mips are approximated with a 2x2 box
	/// filter, as the texture compiler builds them.
	/// </remarks>
	static float[] MipAlphaScales( Color32[] albedo, int size )
	{
		const int levels = 8;
		var scales = new float[levels];
		var alpha = new float[albedo.Length];
		var covered = 0;
		for ( var i = 0; i < albedo.Length; i++ )
		{
			alpha[i] = albedo[i].a / 255.0f;
			if ( alpha[i] > 0.5f ) covered++;
		}

		var share = covered / (float)albedo.Length;
		var w = size;
		var h = size;

		for ( var level = 0; level < levels; level++ )
		{
			var nw = Math.Max( 1, w / 2 );
			var nh = Math.Max( 1, h / 2 );
			var next = new float[nw * nh];
			for ( var y = 0; y < nh; y++ )
			{
				for ( var x = 0; x < nw; x++ )
				{
					var x0 = Math.Min( x * 2, w - 1 ); var x1 = Math.Min( x * 2 + 1, w - 1 );
					var y0 = Math.Min( y * 2, h - 1 ); var y1 = Math.Min( y * 2 + 1, h - 1 );
					next[y * nw + x] = (alpha[y0 * w + x0] + alpha[y0 * w + x1] + alpha[y1 * w + x0] + alpha[y1 * w + x1]) * 0.25f;
				}
			}

			alpha = next; w = nw; h = nh;

			// The alpha value with `share` of this mip's texels above it.
			var sorted = (float[])alpha.Clone();
			Array.Sort( sorted );
			var index = Math.Clamp( (int)((1.0f - share) * sorted.Length), 0, sorted.Length - 1 );
			var threshold = sorted[index];
			scales[level] = threshold > 0.01f ? Math.Clamp( 0.5f / threshold, 1.0f, 4.0f ) : 1.0f;
		}

		return scales;
	}

	static string BuildMaterial( string albedo, string normal, string depth, int depthBits, string frames, string coarse, string rao, int n, FlatpackLayout layout, Vector3 center, float radius,
		bool zLocked, float[] mipAlphaScales, int searchSteps, bool seeThrough, bool bakedMetal )
	{
		string F( float v ) => v.ToString( "0.000000", CultureInfo.InvariantCulture );

		var sb = new StringBuilder();
		sb.AppendLine( "// Flatpack impostor material. Generated by the Flatpack baker (Anakin Studio); re-baking overwrites it." );
		sb.AppendLine( "Layer0" );
		sb.AppendLine( "{" );
		sb.AppendLine( $"\tshader \"{ImpostorShader}\"" );
		sb.AppendLine( $"\tTextureAlbedo \"{albedo}\"" );
		sb.AppendLine( $"\tTextureNormal \"{normal}\"" );
		sb.AppendLine( depthBits == 16 ? $"\tTextureDepth \"{depth}\"" : $"\tTextureDepthBC4 \"{depth}\"" );
		sb.AppendLine( $"\tTextureFrames \"{frames}\"" );
		sb.AppendLine( $"\tTextureDepthMin \"{coarse}\"" );
		sb.AppendLine( $"\tg_flFlatpackCoarse \"{F( 1 )}\"" );
		sb.AppendLine( $"\tg_flFlatpackCoarseBlock \"{F( CoarseBlock )}\"" );
		sb.AppendLine( $"\tg_flFlatpackFramed \"{F( 1 )}\"" );
		sb.AppendLine( $"\tg_flFlatpackDepthBits \"{F( depthBits )}\"" );
		if ( rao is not null )
			sb.AppendLine( $"\tTextureRoughnessAo \"{rao}\"" );
		sb.AppendLine( $"\tg_flFlatpackGrid \"{F( n )}\"" );
		sb.AppendLine( $"\tg_flFlatpackMode \"{F( (int)layout )}\"" );
		sb.AppendLine( $"\tg_vFlatpackCenter \"[{F( center.x )} {F( center.y )} {F( center.z )}]\"" );
		sb.AppendLine( $"\tg_flFlatpackRadius \"{F( radius )}\"" );
		sb.AppendLine( $"\tg_flFlatpackZLocked \"{F( zLocked ? 1 : 0 )}\"" );
		sb.AppendLine( $"\tg_vFlatpackMipAlpha0 \"[{F( mipAlphaScales[0] )} {F( mipAlphaScales[1] )} {F( mipAlphaScales[2] )} {F( mipAlphaScales[3] )}]\"" );
		sb.AppendLine( $"\tg_vFlatpackMipAlpha1 \"[{F( mipAlphaScales[4] )} {F( mipAlphaScales[5] )} {F( mipAlphaScales[6] )} {F( mipAlphaScales[7] )}]\"" );
		sb.AppendLine( $"\tg_flFlatpackSearchSteps \"{F( Math.Clamp( searchSteps, 0, 32 ) )}\"" );
		// See-through bakes start from a typical fit; the bake check fits both to the model after baking.
		sb.AppendLine( $"\tg_flFlatpackSoftCoverage \"{F( seeThrough ? 1.5f : 0.0f )}\"" );
		sb.AppendLine( $"\tg_flFlatpackSoftGrowth \"{F( seeThrough ? 0.5f : 0.0f )}\"" );
		sb.AppendLine( $"\tg_flFlatpackBakedMetal \"{F( bakedMetal ? 1.0f : 0.0f )}\"" );
		sb.AppendLine( "}" );
		return sb.ToString();
	}

	/// <summary>
	/// Where the outputs go, and the Assets folder that asset paths are relative to.
	/// </summary>
	static (string Folder, string AssetsRoot) ResolveOutputFolder( Model model, FlatpackBakeSettings settings )
	{
		var projectAssets = Project.Current.GetAssetsPath();

		if ( !string.IsNullOrWhiteSpace( settings.OutputFolder ) )
		{
			var sub = settings.OutputFolder.Trim().Trim( '/', '\\' );
			return (Path.GetFullPath( Path.Combine( projectAssets, sub ) ), projectAssets);
		}

		// Next to the source, but only when the source is a file in this project (or one of its libraries).
		// Engine, cloud and cached models are read-only to us.
		var source = AssetSystem.FindByPath( model.ResourcePath );
		var projectRoot = Path.GetFullPath( Project.Current.GetRootPath() );
		var absolute = source?.AbsolutePath;

		if ( !string.IsNullOrEmpty( absolute ) )
		{
			absolute = Path.GetFullPath( absolute );
			var inProject = absolute.StartsWith( projectRoot, StringComparison.OrdinalIgnoreCase )
				&& !absolute.Contains( $"{Path.DirectorySeparatorChar}.sbox{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase );

			var assetsRoot = inProject ? FindAssetsRoot( absolute ) : null;
			if ( assetsRoot is not null )
				return (Path.GetDirectoryName( absolute ), assetsRoot);
		}

		return (Path.Combine( projectAssets, "flatpack" ), projectAssets);
	}

	static string FindAssetsRoot( string file )
	{
		var dir = new DirectoryInfo( Path.GetDirectoryName( file ) );
		while ( dir is not null )
		{
			if ( dir.Name.Equals( "Assets", StringComparison.OrdinalIgnoreCase ) )
				return dir.FullName;

			dir = dir.Parent;
		}

		return null;
	}

	static string Relative( string assetsRoot, string file )
	{
		return Path.GetRelativePath( assetsRoot, file ).Replace( '\\', '/' ).ToLowerInvariant();
	}

	static string SafeName( Model model )
	{
		var name = Path.GetFileNameWithoutExtension( model.ResourcePath ?? model.Name ?? "model" );
		var invalid = Path.GetInvalidFileNameChars();
		return new string( name.Select( c => invalid.Contains( c ) || c == ' ' ? '_' : char.ToLowerInvariant( c ) ).ToArray() );
	}
}
anakin.flatpack / Editor/FlatpackBakeWindow.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;

namespace Flatpack.EditorTools;

/// <summary>
/// The models queued in the bake window. A class of its own so a control sheet can edit the list.
/// </summary>
public sealed class FlatpackBakeQueue
{
	/// <summary>
	/// Models to bake, one impostor each.
	/// </summary>
	[Property] public List<Model> Models { get; set; } = new();
}

/// <summary>
/// Editor window for baking impostors: a model list, the bake settings, and a bake button with progress and cancel.
/// Open it from the Editor menu (Flatpack, Impostor Baker), or from a model's right-click menu.
/// </summary>
public sealed class FlatpackBakeWindow : Dialog
{
	const string SettingsCookie = "Flatpack.BakeSettings";

	static FlatpackBakeWindow _current;

	readonly FlatpackBakeQueue _queue = new();
	readonly FlatpackBakeSettings _settings;
	readonly Label _status;
	readonly ProgressBar _bar;
	readonly Button _bake;
	readonly ControlSheet _models;
	CancellationTokenSource _cancel;

	/// <summary>
	/// True while a bake is running in this window.
	/// </summary>
	public bool IsBaking => _cancel is not null;

	[Menu( "Editor", "Flatpack/Impostor Baker" )]
	public static void OpenFromMenu() => Open();

	/// <summary>
	/// Show the bake window (reusing an open one) and queue <paramref name="models"/>.
	/// </summary>
	public static FlatpackBakeWindow Open( params Model[] models )
	{
		if ( _current is not { IsValid: true } )
			_current = new FlatpackBakeWindow();

		_current.Enqueue( models );
		_current.Show();
		return _current;
	}

	FlatpackBakeWindow() : base( EditorWindow )
	{
		_settings = EditorCookie.Get( SettingsCookie, new FlatpackBakeSettings() ) ?? new FlatpackBakeSettings();

		Window.Title = "Flatpack Impostor Baker";
		Window.SetWindowIcon( "view_in_ar" );
		Window.Size = new Vector2( 460, 640 );
		Window.StateCookie = "FlatpackBakeWindow";

		Layout = Layout.Column();
		Layout.Margin = 12;
		Layout.Spacing = 8;

		var title = Layout.Add( new Label( "Flatpack by Anakin Studio" ) );
		title.SetStyles( "font-weight: bold;" );

		Layout.Add( new Label( "Bakes each model into an impostor: atlases, a material and a .fpack resource. Add the .fpack to a Flatpack Renderer." ) { WordWrap = true } );

		_models = new ControlSheet();
		_models.AddObject( _queue.GetSerialized() );
		Layout.Add( _models );

		var settings = new ControlSheet();
		var serializedSettings = _settings.GetSerialized();
		serializedSettings.OnPropertyChanged += _ => EditorCookie.Set( SettingsCookie, _settings );
		settings.AddObject( serializedSettings );
		Layout.Add( settings );

		Layout.AddStretchCell();

		_status = Layout.Add( new Label( "Add models, then bake." ) { WordWrap = true } );
		_bar = Layout.Add( new ProgressBar( this ) );
		_bar.FixedHeight = 8;
		_bar.Visible = false;

		var footer = Layout.AddRow();
		footer.Spacing = 8;
		footer.Add( new Button( "Add Selected", "playlist_add" )
		{
			ToolTip = "Queue the models of the selected objects in the open scene.",
			Clicked = AddSceneSelection,
		} );
		footer.Add( new Button( "Clear", "clear_all" ) { Clicked = () => { _queue.Models.Clear(); Rebuild(); } } );
		footer.Add( new Button( "Apply to Scene", "forest" )
		{
			ToolTip = "Add a Flatpack Renderer to every object in the open scene whose model has a baked impostor.",
			Clicked = () => _status.Text = $"Added Flatpack Renderer to {ApplyToScene()} object(s).",
		} );
		footer.AddStretchCell();
		_bake = footer.Add( new Button.Primary( "Bake", "view_in_ar" ) { Clicked = OnBakeClicked } );

		Show();
	}

	public override void OnDestroyed()
	{
		_cancel?.Cancel();
		if ( _current == this )
			_current = null;

		base.OnDestroyed();
	}

	void Enqueue( IEnumerable<Model> models )
	{
		foreach ( var model in models ?? Enumerable.Empty<Model>() )
		{
			if ( model is not null && !model.IsError && !_queue.Models.Contains( model ) )
				_queue.Models.Add( model );
		}

		Rebuild();
	}

	void Rebuild()
	{
		// The list control doesn't watch the list for changes made from code, so rebuild the sheet.
		_models.Clear( true );
		_models.AddObject( _queue.GetSerialized() );
	}

	void AddSceneSelection()
	{
		var session = SceneEditorSession.Active;
		if ( session is null )
			return;

		var found = session.Selection.OfType<GameObject>()
			.SelectMany( go => go.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
			.Select( r => r.Model )
			.Where( m => m is not null );

		Enqueue( found.ToArray() );
	}

	/// <summary>
	/// Give every <see cref="ModelRenderer"/> in the open scene that has a baked impostor and no
	/// <see cref="FlatpackRenderer"/> one, as a single undo step. Returns how many it added.
	/// </summary>
	public static int ApplyToScene()
	{
		var session = SceneEditorSession.Active;
		if ( session is null )
			return 0;

		var targets = session.Scene.GetAllComponents<ModelRenderer>()
			.Where( r => r.Model is not null && !r.Components.Get<FlatpackRenderer>().IsValid() )
			.Select( r => (Renderer: r, Impostor: FlatpackRenderer.FindImpostorFor( r.Model )) )
			.Where( x => x.Impostor is not null )
			.ToArray();

		if ( targets.Length == 0 )
			return 0;

		using var scene = session.Scene.Push();
		using ( session.UndoScope( "Apply Flatpack to Scene" ).WithComponentCreations().Push() )
		{
			foreach ( var (renderer, impostor) in targets )
			{
				var flatpack = renderer.GameObject.Components.Create<FlatpackRenderer>();
				flatpack.Source = renderer;
				flatpack.Impostor = impostor;
			}
		}

		return targets.Length;
	}

	async void OnBakeClicked()
	{
		if ( IsBaking )
		{
			_cancel.Cancel();
			_bake.Enabled = false;
			_status.Text = "Cancelling...";
			return;
		}

		await BakeQueueAsync();
	}

	/// <summary>
	/// Bake everything queued. Returns the impostors that baked, in queue order.
	/// </summary>
	public async Task<List<FlatpackImpostor>> BakeQueueAsync()
	{
		var results = new List<FlatpackImpostor>();
		var models = _queue.Models.Where( m => m is not null && !m.IsError ).Distinct().ToArray();
		if ( models.Length == 0 )
		{
			_status.Text = "Nothing to bake. Add a model first.";
			return results;
		}

		_cancel = new CancellationTokenSource();
		_bake.Text = "Cancel";
		_bake.Icon = "close";
		_bar.Visible = true;

		using var toast = Editor.Application.Editor.ProgressSection();
		toast.Title = "Baking impostors";
		toast.TotalCount = models.Length;

		var failed = 0;
		try
		{
			for ( var i = 0; i < models.Length; i++ )
			{
				var model = models[i];
				var index = i;

				void Report( float fraction, string text )
				{
					if ( !IsValid )
						return;

					_bar.Fraction = (index + fraction) / models.Length;
					_status.Text = $"{model.ResourceName} ({index + 1} of {models.Length}): {text}";
				}

				try
				{
					toast.Current = i;
					toast.Subtitle = model.ResourceName;

					var result = await FlatpackBaker.BakeAsync( model, _settings, Report, _cancel.Token );
					results.Add( result.Impostor );
					Log.Info( $"Flatpack: baked {model.ResourcePath} to {result.ImpostorPath} in {result.Duration.TotalSeconds:0.0} s" );
				}
				catch ( OperationCanceledException )
				{
					throw;
				}
				catch ( Exception e )
				{
					failed++;
					Log.Warning( $"Flatpack: couldn't bake {model.ResourcePath}: {e.Message}" );
				}

				if ( toast.GetCancel().IsCancellationRequested )
					_cancel.Cancel();
			}

			_status.Text = failed == 0
				? $"Baked {results.Count} impostor{(results.Count == 1 ? "" : "s")}."
				: $"Baked {results.Count}, {failed} failed. See the console.";
		}
		catch ( OperationCanceledException )
		{
			_status.Text = $"Cancelled after {results.Count} of {models.Length}.";
		}
		finally
		{
			_cancel = null;

			if ( IsValid )
			{
				_bake.Text = "Bake";
				_bake.Icon = "view_in_ar";
				_bake.Enabled = true;
				_bar.Visible = false;
			}
		}

		return results;
	}

	/// <summary>
	/// A thin progress bar.
	/// </summary>
	sealed class ProgressBar : Widget
	{
		float _fraction;

		public float Fraction
		{
			get => _fraction;
			set
			{
				_fraction = Math.Clamp( value, 0.0f, 1.0f );
				Update();
			}
		}

		public ProgressBar( Widget parent ) : base( parent ) { }

		protected override void OnPaint()
		{
			Paint.SetPen( Theme.ControlBackground, 1.0f );
			Paint.SetBrush( Theme.WidgetBackground.Darken( 0.1f ) );
			Paint.DrawRect( LocalRect, 2.0f );

			if ( _fraction <= 0.0f )
				return;

			var filled = LocalRect.Shrink( 1 );
			filled.Width *= _fraction;

			Paint.ClearPen();
			Paint.SetBrush( Theme.Primary );
			Paint.DrawRect( filled, 2.0f );
		}
	}
}
anakin.flatpack / Editor/FlatpackQualityCheck.cs
Editor library
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;

namespace Flatpack.EditorTools;

/// <summary>
/// Measures how closely a bake matches its model: renders both in a private scene from a few angles at a range of
/// distances and counts the pixels that differ clearly. The same test as the iteration 3 to 5 benchmarks, cut down to
/// run in a second or two after every bake.
/// </summary>
/// <remarks>
/// Both are rendered through the Albedo debug view, so lighting can't hide or invent differences: what's left is shape
/// (missing thin parts, ghosting, holes) and colour, the failures that matter most for a card. Renders are made at the
/// pixel density of a 1080p screen at 60 degrees FOV, at twice the resolution and averaged down, like the benchmarks.
/// </remarks>
public static class FlatpackQualityCheck
{
	/// <summary>A pixel counts as clearly different past this much average channel difference (of 255).</summary>
	public const int BadPixelThreshold = 25;

	/// <summary>
	/// A bake holds up at a distance when its structural difference from the model (DSSIM, 0..1) is at most this much
	/// above its own floor: the difference that's left far away, where nothing more can be gained (lighting, texture
	/// noise). Calibrated by eye in iteration 5: the chair looked right from 6 radii, the tree from 6 to 8, the knight
	/// from 8. 0.10 reproduces all three; a fixed cut-off on any single score, or a ratio to the floor, didn't.
	/// </summary>
	public const float HoldsUpExcess = 0.10f;

	/// <summary>
	/// Whatever the structure score says, more than this share of clearly wrong pixels (a wire mesh filled in solid, a
	/// missing part) doesn't hold up.
	/// </summary>
	public const float HoldsUpBadShare = 0.25f;

	/// <summary>Distances checked, in bounding radii, nearest first.</summary>
	public static readonly float[] Distances = { 1.5f, 2f, 3f, 4f, 6f, 8f, 12f, 16f, 24f, 30f };

	/// <summary>Viewing directions averaged at each distance: four sides, a little above.</summary>
	static readonly Angles[] Views = { new( 12, 35, 0 ), new( 12, 125, 0 ), new( 12, 215, 0 ), new( 12, 305, 0 ) };

	public sealed class Report
	{
		/// <summary>Share of the object's pixels that differ clearly from the model, per entry of <see cref="Distances"/>.</summary>
		public float[] BadShare { get; init; }

		/// <summary>
		/// Structural difference from the model (1 - SSIM on luminance, 0..1) per entry of <see cref="Distances"/>. Unlike
		/// <see cref="BadShare"/> this sees blur and smearing, which is what makes a card look wrong up close.
		/// </summary>
		public float[] Difference { get; init; }

		/// <summary>The card's difference far away (median of 12 to 30 radii): the floor <see cref="HoldsUpFrom"/> is judged against.</summary>
		public float Floor { get; init; }

		/// <summary>
		/// Nearest distance (bounding radii) from which every checked distance out to 16 radii holds up (see
		/// <see cref="HoldsUpExcess"/> and <see cref="HoldsUpBadShare"/>), or <see cref="float.PositiveInfinity"/> when it
		/// never does.
		/// </summary>
		public float HoldsUpFrom { get; init; }

		/// <summary>
		/// The worst share of clearly different pixels from <paramref name="radii"/> outward (to the furthest checked
		/// distance): what a card swapped in at that distance shows at its worst.
		/// </summary>
		public float WorstFrom( float radii )
		{
			var worst = BadShareAt( radii );
			for ( var i = 0; i < Distances.Length; i++ )
			{
				if ( Distances[i] >= radii && !float.IsNaN( BadShare[i] ) )
					worst = MathF.Max( worst, BadShare[i] );
			}
			return worst;
		}

		/// <summary>One line for status bars and the inspector.</summary>
		/// <summary>The bake's bounding radius in world units, for showing distances in metres.</summary>
		public float Radius { get; init; }

		public string Summary => float.IsPositiveInfinity( HoldsUpFrom )
			? $"Doesn't match the model closely inside 16 radii ({FlatpackMath.FormatMetres( 16.0f * Radius )}): keep the swap distance generous."
			: $"Looks like the model from {HoldsUpFrom:0.#} bounding radii ({FlatpackMath.FormatMetres( HoldsUpFrom * Radius )}) outward.";

		/// <summary>Share of clearly different pixels at <paramref name="radii"/>, interpolated between checked distances.</summary>
		public float BadShareAt( float radii )
		{
			if ( radii <= Distances[0] ) return BadShare[0];
			for ( var i = 1; i < Distances.Length; i++ )
			{
				if ( radii <= Distances[i] )
				{
					var t = (radii - Distances[i - 1]) / (Distances[i] - Distances[i - 1]);
					return BadShare[i - 1] + (BadShare[i] - BadShare[i - 1]) * t;
				}
			}
			return BadShare[^1];
		}
	}

	/// <summary>
	/// For see-through bakes: find the Soft Coverage strength and growth that make the card cover as much of the screen as
	/// the real model does at 8, 16 and 32 bounding radii (where cards show), from two sides, at the pixel density of a
	/// 1080p game view. Both go through the normal lit path (the debug views change how alpha-tested meshes resolve) onto
	/// a white background in an unlit scene, so a covered pixel is a dark one. Leaves the best values set on the
	/// impostor's material and returns them.
	/// </summary>
	public static async Task<(float Soft, float Growth)> FitSoftCoverageAsync( FlatpackImpostor impostor, CancellationToken cancel = default )
	{
		var strengths = new[] { 1.0f, 1.25f, 1.5f, 1.75f, 2.0f, 2.5f, 3.0f, 4.0f };
		var growths = new[] { 0.0f, 0.25f, 0.5f, 0.75f, 1.0f };
		var distances = new[] { 8f, 16f, 32f };
		var views = new[] { Views[0], Views[2] };
		var material = impostor.Material;

		var scene = Scene.CreateEditorScene();
		scene.Name = "Flatpack Coverage Fit";
		try
		{
			ModelRenderer model, card;
			CameraComponent camera;
			using ( scene.Push() )
			{
				model = new GameObject( true, "model" ).Components.Create<ModelRenderer>();
				model.Model = impostor.SourceModel;
				model.LodOverride = 0;
				card = new GameObject( true, "card" ).Components.Create<ModelRenderer>();
				card.Model = impostor.CardModel;
				camera = new GameObject( true, "camera" ).Components.Create<CameraComponent>();
				camera.BackgroundColor = Color.White;
			}

			await WarmUpAsync( scene, camera, card, impostor );

			// Each shot: framing for one distance and side, and the model's covered pixel count there.
			var shots = new List<(Vector3 Position, Rotation Rotation, float Fov, Bitmap Target, long Model)>();
			foreach ( var k in distances )
			{
				var halfAngle = MathF.Atan( 1.15f / k );
				// 1920 pixels across a 70 degree view: about 1371 pixels per unit of tangent.
				var size = Math.Clamp( (int)(1371.0f * 2.0f * MathF.Tan( halfAngle )), 24, 1024 );
				foreach ( var view in views )
				{
					var dir = view.ToRotation().Backward;
					var shot = ( impostor.Center + dir * impostor.Radius * k, Rotation.LookAt( -dir, Vector3.Up ), halfAngle * 2.0f * (180.0f / MathF.PI), new Bitmap( size, size, false ), 0L );
					shot.Item5 = Covered( await Render( scene, camera, model, card, impostor, shot.Item1, shot.Item2, shot.Item3, shot.Item4, false ) );
					shots.Add( shot );
				}
			}

			var best = (Soft: 1.0f, Growth: 1.0f, Error: double.MaxValue);
			foreach ( var soft in strengths )
			{
				foreach ( var growth in growths )
				{
					cancel.ThrowIfCancellationRequested();
					material.Set( "g_flFlatpackSoftCoverage", soft );
					material.Set( "g_flFlatpackSoftGrowth", growth );
					double error = 0;
					foreach ( var shot in shots )
					{
						var c = Covered( await Render( scene, camera, model, card, impostor, shot.Position, shot.Rotation, shot.Fov, shot.Target, true ) );
						var rel = (c - shot.Model) / (double)Math.Max( shot.Model, 1 );
						error += rel * rel;
					}
					if ( error < best.Error )
						best = (soft, growth, error);
				}
			}

			material.Set( "g_flFlatpackSoftCoverage", best.Soft );
			material.Set( "g_flFlatpackSoftGrowth", best.Growth );
			return (best.Soft, best.Growth);
		}
		finally
		{
			scene.Destroy();
		}
	}

	static async Task<Color32[]> Render( Scene scene, CameraComponent camera, ModelRenderer model, ModelRenderer card, FlatpackImpostor impostor,
		Vector3 position, Rotation rotation, float fov, Bitmap target, bool showCard )
	{
		using ( scene.Push() )
		{
			camera.FieldOfView = fov;
			camera.WorldPosition = position;
			camera.WorldRotation = rotation;
			var d = (position - impostor.Center).Length;
			camera.ZNear = MathF.Max( 0.1f, (d - impostor.Radius * 1.2f) * 0.5f );
			camera.ZFar = d + impostor.Radius * 2.0f;
			impostor.MarkAtlasesUsed();
			model.Enabled = !showCard;
			card.Enabled = showCard;
			scene.EditorTick( 0.0f, 0.0f );
			camera.RenderToBitmap( target );
		}
		await Task.Delay( 1 );
		return target.GetPixels32();
	}

	/// <summary>
	/// Render the card a few times, a frame apart, before anything is measured. Right after a bake into a new folder the
	/// new textures aren't on the GPU yet and the first renders read blank ones: in iteration 8 a chair measured from 12
	/// radii straight after its first bake, and from 4 once its textures had loaded.
	/// </summary>
	static async Task WarmUpAsync( Scene scene, CameraComponent camera, ModelRenderer card, FlatpackImpostor impostor )
	{
		var target = new Bitmap( 64, 64, false );
		var dir = new Vector3( 1.0f, 0.0f, 0.3f ).Normal;
		for ( var i = 0; i < 20; i++ )
		{
			using ( scene.Push() )
			{
				camera.FieldOfView = 40.0f;
				camera.WorldPosition = impostor.Center + dir * impostor.Radius * 4.0f;
				camera.WorldRotation = Rotation.LookAt( -dir, Vector3.Up );
				impostor.MarkAtlasesUsed();
				card.Enabled = true;
				scene.EditorTick( 0.0f, 0.0f );
				camera.RenderToBitmap( target );
			}
			await Task.Delay( 30 );
		}
	}

	/// <summary>Pixels clearly darker than the white background.</summary>
	static long Covered( Color32[] pixels )
	{
		long n = 0;
		foreach ( var p in pixels )
			if ( p.r + p.g + p.b < 600 ) n++;
		return n;
	}

	/// <summary>
	/// Compare <paramref name="impostor"/> against its source model. <paramref name="upTo"/> stops early, for a quick check
	/// at one distance (it still checks the distances below it).
	/// </summary>
	public static async Task<Report> MeasureAsync( FlatpackImpostor impostor, float upTo = float.MaxValue, CancellationToken cancel = default )
	{
		var scene = Scene.CreateEditorScene();
		scene.Name = "Flatpack Quality Check";
		var bad = new List<float>();
		var structure = new List<float>();

		try
		{
			ModelRenderer model, card;
			CameraComponent camera;

			using ( scene.Push() )
			{
				var a = new GameObject( true, "model" );
				model = a.Components.Create<ModelRenderer>();
				model.Model = impostor.SourceModel;
				model.LodOverride = 0;

				var b = new GameObject( true, "card" );
				card = b.Components.Create<ModelRenderer>();
				card.Model = impostor.CardModel;

				var eye = new GameObject( true, "camera" );
				camera = eye.Components.Create<CameraComponent>();
				camera.BackgroundColor = Color.Transparent;
				camera.DebugMode = SceneCameraDebugMode.Albedo;
			}

			await WarmUpAsync( scene, camera, card, impostor );

			var r = impostor.Radius;
			foreach ( var k in Distances )
			{
				if ( k > upTo && bad.Count > 0 )
					break;

				// Frame the bounding sphere with a little margin, at the pixel density a 1080p, 60 degree view would give.
				var halfAngle = MathF.Atan( 1.15f / k );
				var size = Math.Clamp( (int)(1080.0f * MathF.Tan( halfAngle ) / MathF.Tan( MathF.PI / 6.0f )), 24, 1024 );
				var target = new Bitmap( size * 2, size * 2, false );

				long badPixels = 0, objectPixels = 0;
				double dssimSum = 0;
				long dssimCount = 0;
				foreach ( var view in Views )
				{
					cancel.ThrowIfCancellationRequested();
					var dir = view.ToRotation().Backward;

					Color32[] m, c;
					using ( scene.Push() )
					{
						camera.FieldOfView = halfAngle * 2.0f * (180.0f / MathF.PI);
						camera.WorldPosition = impostor.Center + dir * r * k;
						camera.WorldRotation = Rotation.LookAt( -dir, Vector3.Up );
						camera.ZNear = MathF.Max( 0.1f, r * (k - 1.2f) * 0.5f );
						camera.ZFar = r * (k + 2.0f);

						impostor.MarkAtlasesUsed();
						model.Enabled = true; card.Enabled = false;
						scene.EditorTick( 0.0f, 0.0f );
						camera.RenderToBitmap( target );
						m = target.GetPixels32();

						model.Enabled = false; card.Enabled = true;
						scene.EditorTick( 0.0f, 0.0f );
						camera.RenderToBitmap( target );
						c = target.GetPixels32();
					}

					Compare( m, c, size * 2, ref badPixels, ref objectPixels );
					Structure( m, c, size * 2, ref dssimSum, ref dssimCount );
					await Task.Delay( 1 );
				}

				bad.Add( objectPixels > 0 ? badPixels / (float)objectPixels : 0.0f );
				structure.Add( dssimCount > 0 ? (float)(dssimSum / dssimCount) : 0.0f );
			}
		}
		finally
		{
			scene.Destroy();
		}

		while ( bad.Count < Distances.Length )
		{
			bad.Add( float.NaN );
			structure.Add( float.NaN );
		}

		var far = new List<float>();
		for ( var i = 0; i < Distances.Length; i++ )
			if ( Distances[i] >= 12.0f && !float.IsNaN( structure[i] ) )
				far.Add( structure[i] );
		far.Sort();
		var floor = far.Count > 0 ? far[far.Count / 2] : float.NaN;

		var holds = float.PositiveInfinity;
		for ( var i = Distances.Length - 1; i >= 0; i-- )
		{
			if ( Distances[i] > 16.0f ) continue;
			if ( float.IsNaN( bad[i] ) || float.IsNaN( floor ) || bad[i] > HoldsUpBadShare || structure[i] > floor + HoldsUpExcess ) break;
			holds = Distances[i];
		}

		return new Report { BadShare = bad.ToArray(), Difference = structure.ToArray(), Floor = floor, HoldsUpFrom = holds, Radius = impostor.Radius };
	}

	/// <summary>
	/// Average 2 x 2 blocks of both renders (a stand-in for anti-aliasing), then count object pixels that differ clearly:
	/// <list type="bullet">
	/// <item>Shape: coverage blurred over 3 x 3 pixels differs by more than a quarter. Blurring forgives a thin wire
	/// drawn one pixel over, but not a part that's missing or a see-through mesh that filled in solid.</item>
	/// <item>Colour: both cover the pixel and the colours differ by more than <see cref="BadPixelThreshold"/>.</item>
	/// </list>
	/// A pixel belongs to the object when either render covers more than half of it.
	/// </summary>
	static void Compare( Color32[] m, Color32[] c, int size, ref long badPixels, ref long objectPixels )
	{
		var half = size / 2;
		var count = half * half;
		var mCover = new float[count];
		var cCover = new float[count];
		var mRgb = new int[count * 3];
		var cRgb = new int[count * 3];

		for ( var y = 0; y < half; y++ )
		{
			for ( var x = 0; x < half; x++ )
			{
				var o = y * half + x;
				for ( var dy = 0; dy < 2; dy++ )
				{
					for ( var dx = 0; dx < 2; dx++ )
					{
						var i = (y * 2 + dy) * size + x * 2 + dx;
						var pm = m[i]; var pc = c[i];
						mCover[o] += pm.a / (4.0f * 255.0f);
						cCover[o] += pc.a / (4.0f * 255.0f);
						mRgb[o * 3] += pm.r; mRgb[o * 3 + 1] += pm.g; mRgb[o * 3 + 2] += pm.b;
						cRgb[o * 3] += pc.r; cRgb[o * 3 + 1] += pc.g; cRgb[o * 3 + 2] += pc.b;
					}
				}
			}
		}

		for ( var y = 0; y < half; y++ )
		{
			for ( var x = 0; x < half; x++ )
			{
				var o = y * half + x;
				if ( mCover[o] <= 0.5f && cCover[o] <= 0.5f )
					continue;

				objectPixels++;

				float mBlur = 0, cBlur = 0, n = 0;
				for ( var dy = -1; dy <= 1; dy++ )
				{
					for ( var dx = -1; dx <= 1; dx++ )
					{
						int yy = y + dy, xx = x + dx;
						if ( yy < 0 || yy >= half || xx < 0 || xx >= half ) continue;
						mBlur += mCover[yy * half + xx];
						cBlur += cCover[yy * half + xx];
						n++;
					}
				}

				if ( MathF.Abs( mBlur - cBlur ) / n > 0.25f )
				{
					badPixels++;
					continue;
				}

				if ( mCover[o] > 0.5f && cCover[o] > 0.5f )
				{
					var diff = (Math.Abs( mRgb[o * 3] - cRgb[o * 3] ) + Math.Abs( mRgb[o * 3 + 1] - cRgb[o * 3 + 1] ) + Math.Abs( mRgb[o * 3 + 2] - cRgb[o * 3 + 2] )) / 12.0f;
					if ( diff > BadPixelThreshold )
						badPixels++;
				}
			}
		}
	}

	/// <summary>
	/// Structural difference (1 - SSIM, Wang et al. 2004) between the two renders' luminance, Gaussian window of 1.5
	/// pixels, at half resolution like <see cref="Compare"/>. Summed over pixels near the object (either render covers
	/// something within a few pixels); uncovered pixels count as the benchmarks' mid grey.
	/// </summary>
	static void Structure( Color32[] m, Color32[] c, int size, ref double sum, ref long count )
	{
		var half = size / 2;
		var n = half * half;
		var a = new float[n];
		var b = new float[n];
		var cover = new float[n];

		for ( var y = 0; y < half; y++ )
		{
			for ( var x = 0; x < half; x++ )
			{
				float la = 0, lb = 0, cv = 0;
				for ( var dy = 0; dy < 2; dy++ )
				{
					for ( var dx = 0; dx < 2; dx++ )
					{
						var i = (y * 2 + dy) * size + x * 2 + dx;
						la += Luma( m[i] );
						lb += Luma( c[i] );
						cv += Math.Max( m[i].a, c[i].a ) / 255.0f;
					}
				}

				var o = y * half + x;
				a[o] = la * 0.25f;
				b[o] = lb * 0.25f;
				cover[o] = cv * 0.25f;
			}
		}

		var muA = Blur( a, half );
		var muB = Blur( b, half );
		var aa = new float[n];
		var bb = new float[n];
		var ab = new float[n];
		for ( var i = 0; i < n; i++ )
		{
			aa[i] = a[i] * a[i];
			bb[i] = b[i] * b[i];
			ab[i] = a[i] * b[i];
		}

		var sAA = Blur( aa, half );
		var sBB = Blur( bb, half );
		var sAB = Blur( ab, half );
		var near = Blur( cover, half );

		const float c1 = (0.01f * 255.0f) * (0.01f * 255.0f);
		const float c2 = (0.03f * 255.0f) * (0.03f * 255.0f);
		for ( var i = 0; i < n; i++ )
		{
			if ( near[i] < 0.02f )
				continue;

			var va = sAA[i] - muA[i] * muA[i];
			var vb = sBB[i] - muB[i] * muB[i];
			var cov = sAB[i] - muA[i] * muB[i];
			var ssim = ((2 * muA[i] * muB[i] + c1) * (2 * cov + c2)) / ((muA[i] * muA[i] + muB[i] * muB[i] + c1) * (va + vb + c2));
			sum += 1.0 - ssim;
			count++;
		}
	}

	static float Luma( Color32 p ) => p.a > 127 ? p.r * 0.2126f + p.g * 0.7152f + p.b * 0.0722f : 128.0f;

	static readonly float[] Kernel = MakeKernel( 1.5f, 4 );

	static float[] MakeKernel( float sigma, int radius )
	{
		var k = new float[radius * 2 + 1];
		var total = 0.0f;
		for ( var i = -radius; i <= radius; i++ )
		{
			k[i + radius] = MathF.Exp( -i * i / (2 * sigma * sigma) );
			total += k[i + radius];
		}

		for ( var i = 0; i < k.Length; i++ )
			k[i] /= total;
		return k;
	}

	/// <summary>Separable Gaussian blur of a square image, clamped at the edges.</summary>
	static float[] Blur( float[] src, int w )
	{
		var r = Kernel.Length / 2;
		var tmp = new float[src.Length];
		var dst = new float[src.Length];
		for ( var y = 0; y < w; y++ )
		{
			for ( var x = 0; x < w; x++ )
			{
				float v = 0;
				for ( var k = -r; k <= r; k++ )
					v += src[y * w + Math.Clamp( x + k, 0, w - 1 )] * Kernel[k + r];
				tmp[y * w + x] = v;
			}
		}

		for ( var y = 0; y < w; y++ )
		{
			for ( var x = 0; x < w; x++ )
			{
				float v = 0;
				for ( var k = -r; k <= r; k++ )
					v += tmp[Math.Clamp( y + k, 0, w - 1 ) * w + x] * Kernel[k + r];
				dst[y * w + x] = v;
			}
		}

		return dst;
	}
}
anakin.flatpack / Code/FlatpackRenderer.cs
Game library
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Flatpack;

/// <summary>
/// One mesh LOD in a <see cref="FlatpackRenderer"/> chain: a model and the camera distance it takes over from.
/// </summary>
public sealed class FlatpackLodLevel
{
	/// <summary>
	/// The model to show in this band. Usually a lower-poly version of the source.
	/// </summary>
	[Property] public Model Model { get; set; }

	/// <summary>
	/// Camera distance where this level replaces the one before it, in world units.
	/// </summary>
	[Property, Range( 0, 20000 )] public float Distance { get; set; } = 1000.0f;
}

/// <summary>
/// Shows a model up close and its Flatpack impostor card far away.
/// </summary>
/// <remarks>
/// <para>
/// Put this next to a <see cref="ModelRenderer"/> and assign a baked <see cref="FlatpackImpostor"/>. The renderer
/// doesn't tick on its own: <see cref="FlatpackSystem"/> checks distances for every renderer in one batched,
/// time-sliced pass, so thousands of instances cost one loop instead of thousands of updates.
/// </para>
/// <para>
/// All cards made from the same impostor share one model and material, so the engine can instance them.
/// </para>
/// <para>
/// LODs: the source <c>.vmdl</c>'s own LODs keep working up close, because this component never touches the
/// <see cref="ModelRenderer"/> until the swap. For separate model files per detail level, fill <see cref="Levels"/>:
/// the renderer swaps the source's model by distance (with the same hysteresis), then hands over to the card past
/// the swap distance. With no <see cref="ModelRenderer"/> at all the card shows at every distance.
/// </para>
/// <para>
/// Alternative without this component: make the card the last LOD of the <c>.vmdl</c> (see <see cref="FlatpackImpostor"/>).
/// That gives you engine LOD switching for free, but no hysteresis and no cross-fade.
/// </para>
/// </remarks>
[Title( "Flatpack Renderer" ), Category( "Rendering" ), Icon( "view_in_ar" )]
public sealed class FlatpackRenderer : Component, Component.ExecuteInEditor
{
	/// <summary>
	/// What to draw.
	/// </summary>
	public enum DisplayMode
	{
		/// <summary>Model inside <see cref="EffectiveSwapDistance"/>, card beyond it.</summary>
		Auto,
		/// <summary>Always the real model.</summary>
		ForceModel,
		/// <summary>Always the card. Useful for checking a bake.</summary>
		ForceCard,
	}

	/// <summary>
	/// The baked impostor to show at distance. Left empty, the renderer uses any baked impostor whose source model
	/// matches the <see cref="Source"/> renderer's model, so adding the component after a bake needs no setup.
	/// </summary>
	[Property] public FlatpackImpostor Impostor { get; set; }

	/// <summary>
	/// The renderer for the real model. Found on this GameObject if left empty.
	/// </summary>
	[Property] public ModelRenderer Source { get; set; }

	/// <summary>
	/// Pick the swap distance from the object's size: the card takes over once the bounding sphere is about
	/// 1/<see cref="AutoDistanceFactor"/> of the distance across, roughly 5% of a 1080p screen's height at 70 degrees FOV.
	/// </summary>
	[Property] public bool AutoSwapDistance { get; set; } = true;

	/// <summary>
	/// Auto mode swaps as soon as the card looks like the model: at the distance the baker measured
	/// (<see cref="FlatpackImpostor.HoldsUpFrom"/>, calibrated by eye), never closer than <see cref="MinimumSwapRadii"/>.
	/// Measured in iteration 6: a 534,000-triangle tree forest drew in 7 to 13 ms swapping at 8 radii against 25 to 28 ms
	/// at 30. Turn it off to swap at <see cref="AutoDistanceFactor"/> instead (bakes that weren't measured always do).
	/// </summary>
	[Property, ShowIf( nameof( AutoSwapDistance ), true )] public bool SwapWhenItLooksRight { get; set; } = true;

	/// <summary>
	/// The closest an early swap may happen, in bounding radii. Up close a card covers many pixels and costs more to
	/// draw, so very light models can end up slower as cards than as models.
	/// </summary>
	[Property, Range( 2, 30 ), ShowIf( nameof( SwapWhenItLooksRight ), true )] public float MinimumSwapRadii { get; set; } = 6.0f;

	/// <summary>
	/// Auto mode swaps at this many bounding-sphere radii from the camera when it isn't swapping early (or the bake
	/// wasn't measured), or where the bake holds up if that's further.
	/// </summary>
	[Property, Range( 5, 100 ), ShowIf( nameof( AutoSwapDistance ), true )] public float AutoDistanceFactor { get; set; } = 30.0f;

	/// <summary>
	/// Camera distance where the card takes over, in world units, when <see cref="AutoSwapDistance"/> is off.
	/// </summary>
	[Property, Range( 0, 20000 ), ShowIf( nameof( AutoSwapDistance ), false )] public float SwapDistance { get; set; } = 2000.0f;

	/// <summary>
	/// The swap distance in use: <see cref="SwapDistance"/>, or the automatic one, then scaled by the global
	/// <see cref="FlatpackSystem.Level"/> (a game's graphics setting).
	/// </summary>
	public float EffectiveSwapDistance
	{
		get
		{
			var (levelScale, levelMinimum) = FlatpackSystem.LevelScaling;
			if ( float.IsPositiveInfinity( levelScale ) )
				return float.PositiveInfinity;

			if ( !AutoSwapDistance || Impostor is null )
				return SwapDistance * levelScale;

			var scale = WorldScale;
			var radius = Impostor.Radius * MathF.Max( scale.x, MathF.Max( scale.y, scale.z ) );
			var measured = Impostor.HoldsUpFrom > 0.0f;
			var factor = SwapWhenItLooksRight && measured
				? MathF.Max( Impostor.HoldsUpFrom, MinimumSwapRadii )
				: MathF.Max( AutoDistanceFactor, Impostor.HoldsUpFrom );

			// A lower graphics level swaps earlier, but never closer than its own minimum.
			factor *= levelScale;
			if ( levelMinimum > 0.0f )
				factor = MathF.Max( factor, levelMinimum );
			return radius * factor;
		}
	}

	/// <summary>
	/// Width of the dead band around <see cref="SwapDistance"/>. The card appears past SwapDistance + Hysteresis / 2
	/// and the model returns inside SwapDistance - Hysteresis / 2, so a camera hovering at the boundary doesn't flicker.
	/// </summary>
	[Property, Range( 0, 2000 )] public float Hysteresis { get; set; } = 200.0f;

	/// <summary>
	/// Dither the card in and out over <see cref="FadeTime"/> instead of popping. The model stays visible under the
	/// card until the fade-in finishes, so the swap reads as a dissolve.
	/// </summary>
	[Property] public bool CrossFade { get; set; } = true;

	/// <summary>
	/// Seconds a cross-fade takes.
	/// </summary>
	[Property, Range( 0, 2 ), ShowIf( nameof( CrossFade ), true )] public float FadeTime { get; set; } = 0.35f;

	/// <summary>
	/// Override the distance check.
	/// </summary>
	[Property] public DisplayMode Mode { get; set; } = DisplayMode.Auto;

	/// <summary>
	/// Optional mesh LODs between the source model and the card, nearest first. Each replaces the source renderer's
	/// model past its <see cref="FlatpackLodLevel.Distance"/>. Distances past <see cref="SwapDistance"/> never show.
	/// </summary>
	[Property] public List<FlatpackLodLevel> Levels { get; set; } = new();

	/// <summary>
	/// Index into <see cref="Levels"/> currently on the source renderer, or -1 for the source's own model.
	/// </summary>
	public int CurrentLevel { get; private set; } = -1;

	/// <summary>
	/// True while the card is the visible representation (including while it fades in).
	/// </summary>
	public bool ShowingCard { get; private set; }

	SceneObject _card;
	Model _baseModel;      // the source renderer's own model, restored when the chain steps back to level -1
	FlatpackImpostor _countedImpostor;   // impostor this renderer's visible card is counted against in the system
	float _fade;           // 0 = card hidden, 1 = card fully shown
	float _fadeTarget;
	internal int SystemIndex = -1;

	protected override void OnEnabled()
	{
		Source ??= GameObject.Components.Get<ModelRenderer>();
		_baseModel = Source.IsValid() ? Source.Model : null;
		Impostor ??= FindImpostorFor( _baseModel );
		CurrentLevel = -1;
		Transform.OnTransformChanged += SyncTransform;

		Scene.GetSystem<FlatpackSystem>()?.Register( this );
	}

	protected override void OnDisabled()
	{
		Transform.OnTransformChanged -= SyncTransform;
		Scene.GetSystem<FlatpackSystem>()?.Unregister( this );

		DestroyCard();
		SetLevel( -1 );
		SetSourceVisible( true );
		ShowingCard = false;
		_fade = _fadeTarget = 0.0f;
	}

	protected override void OnValidate()
	{
		// An edited impostor or a changed mode should show up straight away in the editor.
		DestroyCard();
		SetLevel( -1 );
		ShowingCard = false;
		_fade = _fadeTarget = 0.0f;
		SetSourceVisible( true );
	}

	/// <summary>
	/// Called by <see cref="FlatpackSystem"/> with the squared distance from the camera to this object.
	/// Decides which representation should be showing; the fade itself runs in <see cref="TickFade"/>.
	/// </summary>
	internal bool Evaluate( float distanceSquared )
	{
		UpdateLevel( distanceSquared );

		var wantCard = Mode switch
		{
			DisplayMode.ForceCard => true,
			DisplayMode.ForceModel => false,
			_ => WantCardAt( distanceSquared ),
		};

		// Card-only setup: nothing else to show.
		if ( !Source.IsValid() && Mode != DisplayMode.ForceModel )
			wantCard = true;

		if ( wantCard && Impostor?.Material is null )
			wantCard = false;

		if ( wantCard == ShowingCard )
			return false;

		ShowingCard = wantCard;
		_fadeTarget = wantCard ? 1.0f : 0.0f;

		if ( wantCard )
			EnsureCard();

		if ( !CrossFade || FadeTime <= 0.0f )
			_fade = _fadeTarget;

		Apply();

		// The system keeps calling TickFade while this returns true.
		return _fade != _fadeTarget;
	}

	/// <summary>
	/// Step the mesh LOD chain toward the level for this distance, one band edge at a time, each with the same
	/// hysteresis band as the card swap.
	/// </summary>
	void UpdateLevel( float distanceSquared )
	{
		if ( Levels is null || Levels.Count == 0 || !Source.IsValid() || Mode != DisplayMode.Auto )
			return;

		var level = Math.Min( CurrentLevel, Levels.Count - 1 );
		var half = Hysteresis * 0.5f;

		while ( level + 1 < Levels.Count && Beyond( distanceSquared, Levels[level + 1].Distance + half ) )
			level++;

		while ( level >= 0 && !Beyond( distanceSquared, Levels[level].Distance - half ) )
			level--;

		SetLevel( level );
	}

	static bool Beyond( float distanceSquared, float distance )
	{
		distance = MathF.Max( distance, 0.0f );
		return distanceSquared > distance * distance;
	}

	void SetLevel( int level )
	{
		if ( level == CurrentLevel || !Source.IsValid() )
			return;

		CurrentLevel = level;
		var model = level >= 0 ? Levels[level].Model : _baseModel;
		if ( model is not null )
			Source.Model = model;

		// A new model can mean a new scene object, which starts visible.
		SetSourceVisible( !ShowingCard || _fade < 1.0f );
	}

	/// <summary>
	/// The first baked impostor whose source is <paramref name="model"/>, or null.
	/// </summary>
	public static FlatpackImpostor FindImpostorFor( Model model )
	{
		if ( model is null )
			return null;

		return ResourceLibrary.GetAll<FlatpackImpostor>()
			.FirstOrDefault( x => x.SourceModel is not null && x.SourceModel.ResourcePath == model.ResourcePath );
	}

	bool WantCardAt( float distanceSquared )
	{
		// Hysteresis: the threshold depends on which side we're already on.
		var swap = EffectiveSwapDistance;
		var threshold = ShowingCard
			? swap - Hysteresis * 0.5f
			: swap + Hysteresis * 0.5f;

		threshold = MathF.Max( threshold, 0.0f );
		return distanceSquared > threshold * threshold;
	}

	/// <summary>
	/// Advances a running cross-fade. Returns false when it has finished.
	/// </summary>
	internal bool TickFade( float delta )
	{
		var step = FadeTime > 0.0f ? delta / FadeTime : 1.0f;
		_fade = _fadeTarget > _fade
			? MathF.Min( _fade + step, _fadeTarget )
			: MathF.Max( _fade - step, _fadeTarget );

		Apply();
		return _fade != _fadeTarget;
	}

	void Apply()
	{
		if ( _card.IsValid() )
		{
			_card.RenderingEnabled = _fade > 0.0f;
			CountCard( _fade > 0.0f ? Impostor : null );

			// 1 draws every pixel; anything lower dithers the card out (see flatpack_impostor.shader).
			_card.Attributes.Set( "FlatpackFade", _fade );
		}

		// The model stays up until the card has fully faded in, and comes back the moment we head toward it.
		SetSourceVisible( !ShowingCard || _fade < 1.0f );
	}

	void SetSourceVisible( bool visible )
	{
		if ( Source.IsValid() && Source.SceneObject.IsValid() )
			Source.SceneObject.RenderingEnabled = visible && Source.Enabled;
	}

	void EnsureCard()
	{
		if ( _card.IsValid() || Impostor is null )
			return;

		_card = new SceneObject( Scene.SceneWorld, Impostor.CardModel, WorldTransform );
		_card.Tags.SetFrom( GameObject.Tags );
		_card.RenderingEnabled = false;
	}

	/// <summary>
	/// Keep <see cref="FlatpackSystem"/>'s count of visible cards per impostor current, so it streams their atlases in.
	/// </summary>
	void CountCard( FlatpackImpostor visibleImpostor )
	{
		if ( visibleImpostor == _countedImpostor )
			return;

		var system = Scene?.GetSystem<FlatpackSystem>();
		system?.CardVisibilityChanged( _countedImpostor, false );
		system?.CardVisibilityChanged( visibleImpostor, true );
		_countedImpostor = visibleImpostor;
	}

	void DestroyCard()
	{
		CountCard( null );
		_card?.Delete();
		_card = null;
	}

	void SyncTransform()
	{
		if ( _card.IsValid() )
			_card.Transform = WorldTransform;
	}
}
anakin.flatpack / FlatpackCard.cs
Game library
using System;
using Sandbox;

namespace Flatpack;

/// <summary>
/// Builds the card mesh an impostor is drawn with.
/// </summary>
public static class FlatpackCard
{
	/// <summary>
	/// One quad, two triangles, with the impostor material. The vertex shader moves the corners every frame to
	/// face the camera, using only the UVs; the positions here are a plausible fallback (a quad facing +X through the
	/// bounds centre) for anything that reads the mesh on the CPU.
	/// </summary>
	public static Model Build( FlatpackImpostor impostor )
	{
		return Model.Builder
			.WithName( $"flatpack_card_{impostor.ResourceName}" )
			.AddMesh( BuildMesh( impostor ) )
			.Create();
	}

	/// <summary>
	/// The card quad as a mesh, for adding to another model (the card as the last LOD of a model).
	/// </summary>
	public static Mesh BuildMesh( FlatpackImpostor impostor )
	{
		var c = impostor.Center;
		var r = impostor.Radius;

		var normal = Vector3.Forward;
		var tangent = Vector3.Left;

		// UV (0,0) is the top-left corner; the shader maps u to the card's right and v to its down.
		var vertices = new[]
		{
			new Vertex( c + new Vector3( 0, -r, r ), normal, tangent, new Vector4( 0, 0, 0, 0 ) ),
			new Vertex( c + new Vector3( 0, r, r ), normal, tangent, new Vector4( 1, 0, 0, 0 ) ),
			new Vertex( c + new Vector3( 0, r, -r ), normal, tangent, new Vector4( 1, 1, 0, 0 ) ),
			new Vertex( c + new Vector3( 0, -r, -r ), normal, tangent, new Vector4( 0, 1, 0, 0 ) ),
		};

		var indices = new[] { 0, 1, 2, 0, 2, 3 };

		var mesh = new Mesh( impostor.Material );
		mesh.CreateVertexBuffer( vertices.Length, vertices.AsSpan() );
		mesh.CreateIndexBuffer( indices.Length, indices.AsSpan() );

		// Culling has to use the whole bounding sphere: the card can be at any angle around it, and the shader
		// places it on the sphere's front face.
		mesh.Bounds = BBox.FromPositionAndSize( c, r * 2.0f );
		return mesh;
	}
}
anakin.flatpack / FlatpackMath.cs
Game library
using System;
using System.Collections.Generic;
using Sandbox;

namespace Flatpack;

/// <summary>
/// How the baked views ("frames") are spread over the directions an impostor can be seen from.
/// </summary>
public enum FlatpackLayout
{
	/// <summary>
	/// Hemi-octahedral: the upper half of the sphere. Best for anything that stands on the ground and is never
	/// seen from below (trees, rocks, props). All N x N frames go to the half you actually see.
	/// </summary>
	Hemisphere = 0,

	/// <summary>
	/// Full octahedral: every direction. For floating or tumbling objects that can be seen from below.
	/// </summary>
	Sphere = 1,

	/// <summary>
	/// All N x N frames on the horizon, evenly around Z, and the card only turns around Z. Highest yaw
	/// resolution, but it can't show the top of the object. Classic tree billboard behaviour.
	/// </summary>
	Cylinder = 2,
}

/// <summary>
/// The frame layout maths shared by the baker and the runtime. <c>flatpack_impostor.shader</c> has an HLSL
/// copy of the same functions; they must stay identical or the shader samples the wrong frame.
/// </summary>
/// <remarks>
/// Coordinates are Source/s&amp;box: +X forward, +Y left, +Z up. A frame direction points from the object
/// toward the camera that captured it.
/// </remarks>
public static class FlatpackMath
{
	/// <summary>
	/// Maps a unit direction to the [-1, 1] square for the given layout (not used by <see cref="FlatpackLayout.Cylinder"/>).
	/// </summary>
	public static Vector2 EncodeOct( Vector3 d, FlatpackLayout layout )
	{
		if ( layout == FlatpackLayout.Hemisphere )
		{
			// Nothing below the horizon was baked, so clamp onto the horizon ring.
			d.z = MathF.Max( d.z, 0.0f );
			d = (d + new Vector3( 0, 0, 1e-5f )).Normal;
			var px = d.x / (MathF.Abs( d.x ) + MathF.Abs( d.y ) + d.z);
			var py = d.y / (MathF.Abs( d.x ) + MathF.Abs( d.y ) + d.z);

			// Rotate the octahedron's top half by 45 degrees so it fills the whole square.
			return new Vector2( px + py, px - py );
		}

		var sum = MathF.Abs( d.x ) + MathF.Abs( d.y ) + MathF.Abs( d.z );
		var p = new Vector2( d.x / sum, d.y / sum );
		if ( d.z < 0.0f )
		{
			// Fold the lower half out into the square's corners.
			p = new Vector2( (1.0f - MathF.Abs( p.y )) * Sign( p.x ), (1.0f - MathF.Abs( p.x )) * Sign( p.y ) );
		}

		return p;
	}

	/// <summary>
	/// Inverse of <see cref="EncodeOct"/>. Returns a unit direction.
	/// </summary>
	public static Vector3 DecodeOct( Vector2 o, FlatpackLayout layout )
	{
		if ( layout == FlatpackLayout.Hemisphere )
		{
			var px = (o.x + o.y) * 0.5f;
			var py = (o.x - o.y) * 0.5f;
			return new Vector3( px, py, 1.0f - MathF.Abs( px ) - MathF.Abs( py ) ).Normal;
		}

		var d = new Vector3( o.x, o.y, 1.0f - MathF.Abs( o.x ) - MathF.Abs( o.y ) );
		if ( d.z < 0.0f )
		{
			d.x = (1.0f - MathF.Abs( o.y )) * Sign( o.x );
			d.y = (1.0f - MathF.Abs( o.x )) * Sign( o.y );
		}

		return d.Normal;
	}

	/// <summary>
	/// The direction frame (<paramref name="x"/>, <paramref name="y"/>) of an <paramref name="n"/> x <paramref name="n"/> grid is baked from.
	/// </summary>
	public static Vector3 FrameDirection( int x, int y, int n, FlatpackLayout layout )
	{
		if ( layout == FlatpackLayout.Cylinder )
		{
			var index = y * n + x;
			var yaw = index / (float)(n * n) * MathF.Tau;
			return new Vector3( MathF.Cos( yaw ), MathF.Sin( yaw ), 0.0f );
		}

		// Frame centres sit on the grid vertices (not cell centres), so the outer frames land exactly on the
		// horizon and the shader can blend between them without extrapolating.
		var o = new Vector2( x, y ) / (n - 1) * 2.0f - 1.0f;
		return DecodeOct( o, layout );
	}

	/// <summary>
	/// The camera basis a frame is captured with. Right is image +X, up is image -Y.
	/// </summary>
	public static void FrameBasis( Vector3 d, out Vector3 right, out Vector3 up )
	{
		var refUp = MathF.Abs( d.z ) < 0.999f ? Vector3.Up : Vector3.Forward;
		right = Vector3.Cross( -d, refUp ).Normal;
		up = Vector3.Cross( right, -d );
	}

	/// <summary>
	/// Camera rotation for a frame. Equivalent to <see cref="FrameBasis"/> (checked in the editor: <c>Rotation.LookAt</c>
	/// produces the same right and up vectors).
	/// </summary>
	public static Rotation FrameRotation( Vector3 d )
	{
		FrameBasis( d, out _, out var up );
		return Rotation.LookAt( -d, up );
	}

	/// <summary>
	/// Smallest sphere-ish bound we bake around: the box centre and its half diagonal. Not the tightest sphere,
	/// but stable and cheap, and the shader only needs the frames to contain the object.
	/// </summary>
	public static (Vector3 Center, float Radius) BoundingSphere( BBox bounds )
	{
		return (bounds.Center, MathF.Max( bounds.Size.Length * 0.5f, 0.01f ));
	}

	/// <summary>
	/// A bounding sphere fitted to points (Ritter's method): pick two far-apart points, then grow the sphere for any point
	/// left outside. Within a few percent of the minimal sphere and never smaller than needed.
	/// </summary>
	public static (Vector3 Center, float Radius) BoundingSphere( IReadOnlyList<Vector3> points )
	{
		if ( points is null || points.Count == 0 )
			return (Vector3.Zero, 0.01f);

		var a = points[0];
		var b = Farthest( points, a );
		var c = Farthest( points, b );

		var center = (b + c) * 0.5f;
		var radius = b.Distance( c ) * 0.5f;

		foreach ( var p in points )
		{
			var d = p.Distance( center );
			if ( d <= radius )
				continue;

			// Move the centre toward p just enough that the new sphere still touches the old one's far side.
			var newRadius = (radius + d) * 0.5f;
			center += (p - center) * ((newRadius - radius) / d);
			radius = newRadius;
		}

		// A hair of slack so silhouette texels on the sphere's rim aren't clipped by the frame edge.
		return (center, MathF.Max( radius * 1.01f, 0.01f ));
	}

	/// <summary>
	/// Metres in one world unit. s&amp;box units are inches, as in Source; the iteration 6 distance sheet ("6 radii (21 m)"
	/// for a tree 140 units in radius) used the same conversion.
	/// </summary>
	public const float MetresPerUnit = 0.0254f;

	/// <summary>A distance in world units, in metres.</summary>
	public static float ToMetres( float units ) => units * MetresPerUnit;

	/// <summary>
	/// A distance in world units as a reader wants it next to bounding radii: one decimal under 10 m, whole metres from
	/// there ("2.5 m", "21 m"). The inspector, the bake check summary and the benchmark sheets all use this.
	/// </summary>
	public static string FormatMetres( float units )
	{
		var metres = ToMetres( units );
		return metres < 10.0f
			? $"{metres.ToString( "0.0", System.Globalization.CultureInfo.InvariantCulture )} m"
			: $"{metres.ToString( "0", System.Globalization.CultureInfo.InvariantCulture )} m";
	}

	/// <summary>
	/// Columns the frame lookup texture reserves for image rectangles; the depth ranges sit this far to the right.
	/// The shader's <c>FlatpackFrameFit</c> reads texel (x, y) and (x + 32, y).
	/// </summary>
	public const int FrameFitColumns = 32;

	/// <summary>
	/// The bounds one frame was baked with: where the object sits inside the bounding sphere's frame, and how deep.
	/// </summary>
	public struct FrameFit
	{
		/// <summary>Top-left corner of the frame's image rectangle, in the sphere-sized frame's 0..1 image coordinates (v down).</summary>
		public Vector2 Min;

		/// <summary>Bottom-right corner of the image rectangle.</summary>
		public Vector2 Max;

		/// <summary>Height of the nearest surface along the frame direction (toward its camera), in bounding radii.</summary>
		public float Near;

		/// <summary>Height of the furthest surface, in bounding radii. Always below <see cref="Near"/>.</summary>
		public float Far;

		/// <summary>The whole bounding sphere: what every frame used before iteration 8.</summary>
		public static FrameFit Sphere => new() { Min = Vector2.Zero, Max = Vector2.One, Near = 1.0f, Far = -1.0f };
	}

	/// <summary>
	/// Fit one frame to the points it sees: their image rectangle, grown by <paramref name="padTexels"/> of a
	/// <paramref name="cellTexels"/>-wide frame on every side, and their depth range. Rounded outward to the 8-bit steps the
	/// lookup texture stores, so the baker renders with exactly the bounds the shader reads back.
	/// </summary>
	public static FrameFit FitFrame( IReadOnlyList<Vector3> points, Vector3 center, float radius, Vector3 dir, int cellTexels, int padTexels )
	{
		if ( points is null || points.Count == 0 )
			return FrameFit.Sphere;

		FrameBasis( dir, out var right, out var up );
		float minU = float.MaxValue, maxU = float.MinValue, minV = float.MaxValue, maxV = float.MinValue;
		float near = float.MinValue, far = float.MaxValue;

		foreach ( var p in points )
		{
			var rel = p - center;
			var u = 0.5f + Vector3.Dot( rel, right ) / (2.0f * radius);
			var v = 0.5f - Vector3.Dot( rel, up ) / (2.0f * radius);
			var h = Vector3.Dot( rel, dir ) / radius;
			minU = MathF.Min( minU, u ); maxU = MathF.Max( maxU, u );
			minV = MathF.Min( minV, v ); maxV = MathF.Max( maxV, v );
			near = MathF.Max( near, h ); far = MathF.Min( far, h );
		}

		// Padding: the rectangle maps onto cellTexels texels, padTexels of which on each side stay outside the object.
		var grow = cellTexels > 2 * padTexels + 1 ? cellTexels / (float)(cellTexels - 2 * padTexels) : 1.0f;
		var padU = (maxU - minU) * (grow - 1.0f) * 0.5f;
		var padV = (maxV - minV) * (grow - 1.0f) * 0.5f;

		var fit = new FrameFit
		{
			Min = new Vector2( minU - padU, minV - padV ),
			Max = new Vector2( maxU + padU, maxV + padV ),
			Near = near,
			Far = far,
		};
		return DecodeFrameFit( EncodeFrameFit( fit ) );
	}

	/// <summary>
	/// The two lookup texels for a frame: (left, top, right) and (bottom, near, far), each value one byte, rounded outward
	/// so the stored bounds always contain the real ones. Alpha stays 255 (the PNG path drops colour under low alpha).
	/// </summary>
	public static (Color32 Rect, Color32 Depth) EncodeFrameFit( FrameFit fit )
	{
		int Down( float v ) => Math.Clamp( (int)MathF.Floor( v * 255.0f + 1e-3f ), 0, 255 );
		int Up( float v ) => Math.Clamp( (int)MathF.Ceiling( v * 255.0f - 1e-3f ), 0, 255 );

		int minU = Down( fit.Min.x ), maxU = Up( fit.Max.x );
		int minV = Down( fit.Min.y ), maxV = Up( fit.Max.y );
		int near = Up( (fit.Near + 1.0f) * 0.5f ), far = Down( (fit.Far + 1.0f) * 0.5f );

		// Never let a frame collapse (a flat panel seen edge-on, a plane seen face-on): keep at least two steps each way.
		(minU, maxU) = Widen( minU, maxU );
		(minV, maxV) = Widen( minV, maxV );
		(far, near) = Widen( far, near );

		return (new Color32( (byte)minU, (byte)minV, (byte)maxU, 255 ), new Color32( (byte)maxV, (byte)near, (byte)far, 255 ));
	}

	/// <summary>
	/// The bounds two lookup texels describe. Matches the shader's <c>FlatpackFrameFit</c>.
	/// </summary>
	public static FrameFit DecodeFrameFit( (Color32 Rect, Color32 Depth) texels ) => new()
	{
		Min = new Vector2( texels.Rect.r / 255.0f, texels.Rect.g / 255.0f ),
		Max = new Vector2( texels.Rect.b / 255.0f, texels.Depth.r / 255.0f ),
		Near = texels.Depth.g / 255.0f * 2.0f - 1.0f,
		Far = texels.Depth.b / 255.0f * 2.0f - 1.0f,
	};

	static (int Low, int High) Widen( int low, int high )
	{
		while ( high - low < 2 )
		{
			if ( high < 255 ) high++;
			if ( high - low < 2 && low > 0 ) low--;
		}
		return (low, high);
	}

	static Vector3 Farthest( IReadOnlyList<Vector3> points, Vector3 from )
	{
		var best = from;
		var bestDistance = -1.0f;
		foreach ( var p in points )
		{
			var d = p.DistanceSquared( from );
			if ( d > bestDistance )
			{
				bestDistance = d;
				best = p;
			}
		}

		return best;
	}

	static float Sign( float v ) => v >= 0.0f ? 1.0f : -1.0f;
}
anakin.flatpack / Code/FlatpackSystem.cs
Game library
using System;
using System.Collections.Generic;
using Sandbox;

namespace Flatpack;

/// <summary>
/// How hard Flatpack works, for a game's graphics settings: high quality presets turn it off, low ones lean on it.
/// Set <see cref="FlatpackSystem.Level"/>; every Flatpack Renderer follows it within a few frames, nothing re-bakes.
/// </summary>
public enum FlatpackLevel
{
	/// <summary>Real models everywhere; cards never show (a game's High or Ultra preset).</summary>
	Off,

	/// <summary>Swap where the bake was measured to look like the model (the default; a game's Medium).</summary>
	Balanced,

	/// <summary>Swap at 0.6 times the distance, from 3 bounding radii (a game's Low).</summary>
	Performance,

	/// <summary>Swap at 0.35 times the distance, from 1.5 bounding radii (a game's lowest preset or weak hardware).</summary>
	Maximum,
}

/// <summary>
/// Runs the model/card distance check for every <see cref="FlatpackRenderer"/> in the scene.
/// </summary>
/// <remarks>
/// One loop per frame instead of one Update per renderer. The loop is time-sliced: each frame checks at most
/// <see cref="RenderersPerFrame"/> renderers, round-robin, so a forest of 5000 trees spreads its checks over a few
/// frames. Renderers in the middle of a cross-fade are ticked every frame until they finish.
/// </remarks>
public sealed class FlatpackSystem : GameObjectSystem<FlatpackSystem>
{
	/// <summary>
	/// Global level for every Flatpack Renderer, meant to be driven by a game's graphics settings, for example
	/// <c>FlatpackSystem.Level = FlatpackLevel.Performance;</c> on the Low preset. Applies to automatic and manual swap
	/// distances alike; Force Model and Force Card modes ignore it.
	/// </summary>
	public static FlatpackLevel Level { get; set; } = FlatpackLevel.Balanced;

	/// <summary>
	/// What <see cref="Level"/> does to a swap distance: a multiplier, and the closest swap allowed in bounding radii
	/// (0 = the renderer's own minimum). <see cref="FlatpackLevel.Off"/> pushes the swap out to infinity.
	/// </summary>
	public static (float Scale, float MinimumRadii) LevelScaling => Level switch
	{
		FlatpackLevel.Off => (float.PositiveInfinity, 0.0f),
		FlatpackLevel.Performance => (0.6f, 3.0f),
		FlatpackLevel.Maximum => (0.35f, 1.5f),
		_ => (1.0f, 0.0f),
	};

	/// <summary>
	/// Distance checks per frame. Lower spreads the work thinner but makes swaps lag behind a fast camera.
	/// </summary>
	public int RenderersPerFrame { get; set; } = 2048;

	/// <summary>
	/// Number of registered renderers.
	/// </summary>
	public int Count => _renderers.Count;

	readonly List<FlatpackRenderer> _renderers = new();
	readonly List<FlatpackRenderer> _fading = new();
	readonly Dictionary<FlatpackImpostor, int> _visibleCards = new();
	int _cursor;

	public FlatpackSystem( Scene scene ) : base( scene )
	{
		// After the scene update, so this frame's camera and object movement are already in.
		Listen( Stage.FinishUpdate, 100, Tick, "Flatpack.DistancePass" );
	}

	internal void Register( FlatpackRenderer renderer )
	{
		if ( renderer.SystemIndex >= 0 )
			return;

		renderer.SystemIndex = _renderers.Count;
		_renderers.Add( renderer );
	}

	internal void Unregister( FlatpackRenderer renderer )
	{
		var index = renderer.SystemIndex;
		if ( index < 0 || index >= _renderers.Count || _renderers[index] != renderer )
			return;

		// Swap-remove keeps this O(1); the moved renderer takes over the freed slot.
		var last = _renderers[^1];
		_renderers[index] = last;
		last.SystemIndex = index;
		_renderers.RemoveAt( _renderers.Count - 1 );

		renderer.SystemIndex = -1;
		_fading.Remove( renderer );
	}

	/// <summary>
	/// Reference count of visible cards per impostor, kept by <see cref="FlatpackRenderer"/>, so the tick can keep
	/// those atlases streamed in without walking every renderer.
	/// </summary>
	internal void CardVisibilityChanged( FlatpackImpostor impostor, bool visible )
	{
		if ( impostor is null )
			return;

		_visibleCards.TryGetValue( impostor, out var count );
		count += visible ? 1 : -1;

		if ( count > 0 )
			_visibleCards[impostor] = count;
		else
			_visibleCards.Remove( impostor );
	}

	void Tick()
	{
		foreach ( var impostor in _visibleCards.Keys )
			impostor.MarkAtlasesUsed();

		if ( _renderers.Count == 0 )
			return;

		var camera = Scene.Camera;
		if ( !camera.IsValid() )
			return;

		var cameraPosition = camera.WorldPosition;
		var count = Math.Min( RenderersPerFrame, _renderers.Count );

		for ( var i = 0; i < count; i++ )
		{
			if ( _cursor >= _renderers.Count )
				_cursor = 0;

			var renderer = _renderers[_cursor++];
			var distanceSquared = cameraPosition.DistanceSquared( renderer.WorldPosition );

			if ( renderer.Evaluate( distanceSquared ) && !_fading.Contains( renderer ) )
				_fading.Add( renderer );
		}

		if ( _fading.Count == 0 )
			return;

		var delta = Time.Delta;
		for ( var i = _fading.Count - 1; i >= 0; i-- )
		{
			if ( !_fading[i].TickFade( delta ) )
				_fading.RemoveAt( i );
		}
	}
}
anakin.flatpack / .obj/__compiler_extra.cs
Game library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Flatpack" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "flatpack" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "anakin" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "anakin.flatpack" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "30" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]

[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-10-26T15:38:29.8717994Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.112.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.112.0")]
anakin.flatpack / Editor/FlatpackLodModel.cs
Editor library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Editor;
using Sandbox;

namespace Flatpack.EditorTools;

/// <summary>
/// Builds a model whose last LOD is the Flatpack card: the source model's first LODs with their own materials, then the
/// card. The engine then swaps to the card by itself, like any other LOD, with no Flatpack Renderer on the object, and
/// the Clutter tool can paint it.
/// </summary>
/// <remarks>
/// <para>
/// The engine picks a LOD from the camera distance alone: LOD L shows once <c>switch[L] * scale</c> is below
/// <c>100 * distance * tan(fov / 2) / screenWidth</c> (the native metric, also in <c>clutter_cull_cs.shader</c>). Switch
/// values therefore shift with resolution and field of view; <see cref="SwitchValue"/> converts a world distance using a
/// 1920-pixel-wide, 70 degree reference view.
/// </para>
/// <para>
/// Clutter draws at most four LODs and only the first mesh of each, so the source keeps at most three LODs here and each
/// LOD is one mesh with a draw call per material.
/// </para>
/// </remarks>
public static class FlatpackLodModel
{
	/// <summary>Source LODs kept by default: Clutter draws four LODs at most, and the card takes one.</summary>
	public const int ClutterSourceLods = 3;

	const float ReferenceWidth = 1920.0f;
	const float ReferenceFov = 70.0f;

	/// <summary>
	/// The LOD switch value that makes the engine swap at <paramref name="distance"/> world units for an unscaled object,
	/// on the reference view.
	/// </summary>
	public static float SwitchValue( float distance ) =>
		100.0f * distance * MathF.Tan( ReferenceFov * 0.5f * MathF.PI / 180.0f ) / ReferenceWidth;

	/// <summary>
	/// Build the combined model. <paramref name="swapDistance"/> is where the card takes over, in world units for an
	/// unscaled object; source LODs that would switch in at or past it are dropped.
	/// </summary>
	public static Model Build( FlatpackImpostor impostor, float swapDistance, int maxSourceLods = ClutterSourceLods )
	{
		var source = impostor.SourceModel ?? throw new ArgumentException( "The impostor has no source model." );
		var info = source.MeshInfo;
		var switches = info.LodSwitchDistances;
		var cardSwitch = SwitchValue( swapDistance );

		var lods = Math.Clamp( info.LodCount, 1, Math.Max( 1, maxSourceLods ) );
		while ( lods > 1 && switches.Length > lods - 1 && switches[lods - 1] >= cardSwitch )
			lods--;

		var builder = Model.Builder.WithName( $"{source.ResourceName}_flatpack" );
		var bounds = BBox.FromPositionAndSize( impostor.Center, impostor.Radius * 2.0f );

		for ( var lod = 0; lod < lods; lod++ )
		{
			var (mesh, meshBounds) = BuildSourceLod( source, info, lod );
			builder.AddMesh( mesh, lod );
			builder.WithLodDistance( lod, lod < switches.Length ? switches[lod] : lod * 50.0f );
			bounds = bounds.AddBBox( meshBounds );
		}

		// The card from here to the last slot. Every slot gets a switch value; empty slots past the card would be picked
		// at long range and draw nothing.
		for ( var lod = lods; lod < 8; lod++ )
		{
			builder.AddMesh( FlatpackCard.BuildMesh( impostor ), lod );
			builder.WithLodDistance( lod, cardSwitch );
		}

		return builder.WithViewBounds( bounds ).Create();
	}

	/// <summary>
	/// One source LOD as a single mesh: every draw call in that LOD, one after another in shared buffers, each with its
	/// own material.
	/// </summary>
	static (Mesh Mesh, BBox Bounds) BuildSourceLod( Model source, Model.ModelMeshInfo info, int lod )
	{
		var vertices = new List<Vertex>();
		var indices = new List<int>();
		var ranges = new List<(Material Material, int Start, int Count)>();
		var bounds = new BBox( Vector3.Zero, Vector3.Zero );
		var first = true;

		for ( var m = 0; m < info.Meshes.Length; m++ )
		{
			var meshInfo = info.Meshes[m];
			if ( ((long)meshInfo.LodMask & (1L << lod)) == 0 )
				continue;

			var box = new BBox( meshInfo.BoundsMin, meshInfo.BoundsMax );
			bounds = first ? box : bounds.AddBBox( box );
			first = false;

			for ( var d = 0; d < meshInfo.DrawCalls.Length; d++ )
			{
				if ( !TryReadDrawCall( source, m, d, out var v, out var ix ) )
					continue;

				var start = indices.Count;
				var baseVertex = vertices.Count;
				vertices.AddRange( v );
				foreach ( var i in ix )
					indices.Add( (int)i + baseVertex );

				ranges.Add( (meshInfo.DrawCalls[d].Material, start, ix.Length) );
			}
		}

		if ( ranges.Count == 0 )
			throw new InvalidOperationException( $"Couldn't read LOD {lod} of {source.ResourceName}." );

		var mesh = new Mesh( ranges[0].Material );
		mesh.CreateVertexBuffer( vertices.Count, vertices );
		mesh.CreateIndexBuffer( indices.Count, indices );
		mesh.SetIndexRange( ranges[0].Start, ranges[0].Count );
		for ( var r = 1; r < ranges.Count; r++ )
			mesh.AddSubMesh( ranges[r].Material, ranges[r].Start, ranges[r].Count, 0, 0 );

		mesh.Bounds = bounds;
		return (mesh, bounds);
	}

	static MethodInfo _readDrawCall;

	/// <summary>
	/// The engine's reader for one draw call's vertices and indices. It's internal (the tools' scene compiler uses it), so
	/// it's reached by reflection; the public whole-model readers don't separate LODs or draw calls.
	/// </summary>
	static bool TryReadDrawCall( Model model, int mesh, int drawCall, out Vertex[] vertices, out uint[] indices )
	{
		_readDrawCall ??= typeof( Model ).GetMethod( "TryReadDrawCall", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic );
		vertices = null;
		indices = null;
		if ( _readDrawCall is null )
			return false;

		var args = new object[] { mesh, drawCall, null, null, null };
		if ( _readDrawCall.Invoke( model, args ) is not true )
			return false;

		vertices = args[2] as Vertex[];
		indices = args[3] as uint[];
		return vertices is { Length: > 0 } && indices is { Length: > 0 };
	}

	/// <summary>
	/// Save <paramref name="model"/> as a compiled model next to the impostor's <c>.fpack</c> and load it back from disk,
	/// so scenes and Clutter can reference it by path. Returns the loaded model.
	/// </summary>
	public static async Task<Model> SaveAsync( FlatpackImpostor impostor, Model model )
	{
		var fpack = AssetSystem.FindByPath( impostor.ResourcePath ) ?? throw new InvalidOperationException( "Save the impostor first." );
		var folder = Path.GetDirectoryName( fpack.AbsolutePath );
		var name = $"{impostor.SourceModel.ResourceName}_flatpack";
		var file = Path.Combine( folder, $"{name}.vmdl_c" );

		var bytes = await model.SaveToVmdlAsync();
		await File.WriteAllBytesAsync( file, bytes );

		AssetSystem.RegisterFile( file );
		var relative = Path.Combine( Path.GetDirectoryName( impostor.ResourcePath ) ?? "", $"{name}.vmdl" ).Replace( '\\', '/' );
		return Model.Load( relative );
	}
}
anakin.flatpack / Editor/FlatpackMenus.cs
Editor library
using System;
using System.Linq;
using Editor;
using Sandbox;

namespace Flatpack.EditorTools;

/// <summary>
/// Right-click entry points into the baker: on model assets in the asset browser, and on objects in the scene view.
/// </summary>
public static class FlatpackMenus
{
	[Event( "asset.contextmenu", Priority = 60 )]
	static void OnAssetContextMenu( AssetContextMenu e )
	{
		var models = e.SelectedList
			.Select( x => x.Asset )
			.Where( a => a is not null && a.AssetType == AssetType.Model )
			.ToArray();

		if ( models.Length == 0 )
			return;

		var label = models.Length == 1 ? "Bake Flatpack Impostor" : $"Bake {models.Length} Flatpack Impostors";
		e.Menu.AddOption( label, "view_in_ar", () =>
		{
			var loaded = models.Select( a => Model.Load( a.Path ) ).Where( m => m is not null && !m.IsError ).ToArray();
			FlatpackBakeWindow.Open( loaded );
		} );
	}

	/// <summary>
	/// Bake the model on <paramref name="renderer"/> with the window's current settings, then put a
	/// <see cref="FlatpackRenderer"/> next to it pointing at the result (undoable).
	/// </summary>
	public static async void BakeAndAttach( ModelRenderer renderer )
	{
		if ( !renderer.IsValid() || renderer.Model is null )
			return;

		var window = FlatpackBakeWindow.Open( renderer.Model );
		if ( window.IsBaking )
			return;

		var impostors = await window.BakeQueueAsync();
		var impostor = impostors.FirstOrDefault( x => x.SourceModel == renderer.Model );
		if ( impostor is null || !renderer.IsValid() )
			return;

		var go = renderer.GameObject;
		var session = SceneEditorSession.Resolve( go );
		if ( session is null )
			return;

		using var scene = session.Scene.Push();
		using ( session.UndoScope( "Add Flatpack Renderer" ).WithComponentCreations().WithComponentChanges( go.Components.GetAll<FlatpackRenderer>() ).Push() )
		{
			var flatpack = go.Components.GetOrCreate<FlatpackRenderer>();
			flatpack.Source = renderer;
			flatpack.Impostor = impostor;
		}
	}
}

/// <summary>
/// Adds "Bake Flatpack Impostor" to the scene viewport's right-click menu when you click an object with a
/// <see cref="ModelRenderer"/>. Scene view hooks are instance interfaces, so one listener is registered on first use.
/// </summary>
sealed class FlatpackSceneViewHooks : EditorEvent.ISceneView
{
	static FlatpackSceneViewHooks _instance;

	[EditorEvent.Frame]
	static void EnsureRegistered()
	{
		if ( _instance is not null )
			return;

		_instance = new FlatpackSceneViewHooks();
		EditorEvent.Register( _instance );
	}

	void EditorEvent.ISceneView.DrawGizmos( Scene scene ) { }

	void EditorEvent.ISceneView.ShowContextMenu( EditorEvent.ShowContextMenuEvent ev )
	{
		var target = ev.Trace?.GameObject;
		var renderer = target?.Components.Get<ModelRenderer>( FindMode.EverythingInSelfAndAncestors );
		if ( !renderer.IsValid() || renderer.Model is null )
			return;

		ev.Menu.AddSeparator();
		ev.Menu.AddOption( $"Bake Flatpack Impostor ({renderer.Model.ResourceName})", "view_in_ar", () => FlatpackMenus.BakeAndAttach( renderer ) );
	}
}
anakin.flatpack / Editor/FlatpackRendererEditor.cs
Editor library
using System;
using System.Linq;
using Editor;
using Sandbox;

namespace Flatpack.EditorTools;

/// <summary>
/// Inspector for <see cref="FlatpackRenderer"/>: the usual properties plus a bake button, so giving one model a
/// Flatpack LOD is "add component, press Bake".
/// </summary>
[CustomEditor( typeof( FlatpackRenderer ) )]
public sealed class FlatpackRendererEditor : ComponentEditorWidget
{
	public FlatpackRendererEditor( SerializedObject obj ) : base( obj )
	{
		SetSizeMode( SizeMode.Default, SizeMode.Default );

		Layout = Layout.Column();

		var sheet = new ControlSheet();
		// Only the component's [Property] members; the default sheet hides transform and internals the same way.
		sheet.AddObject( obj, p => p.HasAttribute<PropertyAttribute>() );
		Layout.Add( sheet );

		var renderers = obj.Targets.OfType<FlatpackRenderer>().ToArray();
		var baked = renderers.All( x => x.Impostor is not null );

		// What the bake is, how close it holds up, and whether the swap distance is inside that.
		if ( renderers.Length == 1 && renderers[0].Impostor is { } impostor )
		{
			var r = renderers[0];

			// Radii are the bake's own bounding radius times this object's scale, so metres follow the object's real size.
			var scale = r.WorldScale;
			var radius = impostor.Radius * MathF.Max( scale.x, MathF.Max( scale.y, scale.z ) );
			var swapRadii = radius > 0 ? r.EffectiveSwapDistance / radius : 0;
			var holds = impostor.HoldsUpFrom switch
			{
				> 0 => $"matches the model from {impostor.HoldsUpFrom:0.#} radii ({FlatpackMath.FormatMetres( impostor.HoldsUpFrom * radius )})",
				< 0 => $"never matches the model closely inside 16 radii ({FlatpackMath.FormatMetres( 16.0f * radius )})",
				_ => "not measured (re-bake to measure)",
			};
			var level = string.IsNullOrEmpty( impostor.QualityLevel ) ? "" : $"{impostor.QualityLevel} bake, ";
			var info = Layout.Add( new Label( $"{level}{holds}. Swaps at {swapRadii:0.#} radii ({FlatpackMath.FormatMetres( r.EffectiveSwapDistance )})." ) { WordWrap = true } );
			info.ContentMargins = new Sandbox.UI.Margin( 8, 4, 8, 0 );
			if ( impostor.HoldsUpFrom < 0 )
				info.SetStyles( "color: #e0a030;" );
		}

		var row = Layout.AddRow();
		row.Margin = new Sandbox.UI.Margin( 8, 4, 8, 8 );
		row.Spacing = 4;

		row.Add( new Button( baked ? "Re-bake Impostor" : "Bake Impostor", "view_in_ar" )
		{
			ToolTip = "Bake this object's model with the Impostor Baker's current settings and assign the result.",
			Clicked = () =>
			{
				foreach ( var renderer in renderers )
				{
					var source = renderer.Source ?? renderer.GameObject.Components.Get<ModelRenderer>();
					if ( source.IsValid() )
						FlatpackMenus.BakeAndAttach( source );
				}
			}
		} );

		row.Add( new Button( "Bake Settings", "tune" )
		{
			ToolTip = "Open the Impostor Baker window to change grid, atlas size and layout.",
			Clicked = () => FlatpackBakeWindow.Open()
		} );

		row.AddStretchCell();
	}
}
anakin.flatpack / Code/FlatpackCard.cs
Game library
using System;
using Sandbox;

namespace Flatpack;

/// <summary>
/// Builds the card mesh an impostor is drawn with.
/// </summary>
public static class FlatpackCard
{
	/// <summary>
	/// One quad, two triangles, with the impostor material. The vertex shader moves the corners every frame to
	/// face the camera, using only the UVs; the positions here are a plausible fallback (a quad facing +X through the
	/// bounds centre) for anything that reads the mesh on the CPU.
	/// </summary>
	public static Model Build( FlatpackImpostor impostor )
	{
		return Model.Builder
			.WithName( $"flatpack_card_{impostor.ResourceName}" )
			.AddMesh( BuildMesh( impostor ) )
			.Create();
	}

	/// <summary>
	/// The card quad as a mesh, for adding to another model (the card as the last LOD of a model).
	/// </summary>
	public static Mesh BuildMesh( FlatpackImpostor impostor )
	{
		var c = impostor.Center;
		var r = impostor.Radius;

		var normal = Vector3.Forward;
		var tangent = Vector3.Left;

		// UV (0,0) is the top-left corner; the shader maps u to the card's right and v to its down.
		var vertices = new[]
		{
			new Vertex( c + new Vector3( 0, -r, r ), normal, tangent, new Vector4( 0, 0, 0, 0 ) ),
			new Vertex( c + new Vector3( 0, r, r ), normal, tangent, new Vector4( 1, 0, 0, 0 ) ),
			new Vertex( c + new Vector3( 0, r, -r ), normal, tangent, new Vector4( 1, 1, 0, 0 ) ),
			new Vertex( c + new Vector3( 0, -r, -r ), normal, tangent, new Vector4( 0, 1, 0, 0 ) ),
		};

		var indices = new[] { 0, 1, 2, 0, 2, 3 };

		var mesh = new Mesh( impostor.Material );
		mesh.CreateVertexBuffer( vertices.Length, vertices.AsSpan() );
		mesh.CreateIndexBuffer( indices.Length, indices.AsSpan() );

		// Culling has to use the whole bounding sphere: the card can be at any angle around it, and the shader
		// places it on the sphere's front face.
		mesh.Bounds = BBox.FromPositionAndSize( c, r * 2.0f );
		return mesh;
	}
}
anakin.flatpack / Code/FlatpackImpostor.cs
Game library
using System.Linq;
using System.Text.Json.Serialization;
using Sandbox;

namespace Flatpack;

/// <summary>
/// A baked impostor: the atlas material plus everything the runtime needs to place the card.
/// Created by the Flatpack baker (Editor menu, Flatpack, Impostor Baker), one per source model.
/// </summary>
/// <remarks>
/// <para>Use it with <see cref="FlatpackRenderer"/>, which swaps between the real model and the card by distance.</para>
/// <para>
/// To use the card as the last LOD of a <c>.vmdl</c> instead, add a LOD to the model in ModelDoc whose only mesh is a
/// single quad (any size, centred on the model) with this impostor's material, UVs (0,0) top-left to (1,1)
/// bottom-right. The shader ignores the quad's positions and rebuilds the card from its UVs, the bake bounds and the
/// instance transform, so the quad's size and orientation don't matter. Flatpack doesn't write <c>.vmdl</c> files for you.
/// </para>
/// </remarks>
[AssetType( Name = "Flatpack Impostor", Extension = "fpack", Category = "Rendering" )]
public sealed class FlatpackImpostor : GameResource
{
	/// <summary>
	/// The model this impostor was baked from.
	/// </summary>
	[Property] public Model SourceModel { get; set; }

	/// <summary>
	/// Material using <c>shaders/flatpack_impostor.shader</c> with the baked atlases.
	/// </summary>
	[Property] public Material Material { get; set; }

	/// <summary>
	/// How the frames are spread over view directions.
	/// </summary>
	[Property, ReadOnly] public FlatpackLayout Layout { get; set; } = FlatpackLayout.Hemisphere;

	/// <summary>
	/// Frames per atlas side. The atlas holds GridSize x GridSize frames.
	/// </summary>
	[Property, ReadOnly] public int GridSize { get; set; } = 8;

	/// <summary>
	/// Atlas width and height in pixels.
	/// </summary>
	[Property, ReadOnly] public int AtlasSize { get; set; } = 2048;

	/// <summary>
	/// Centre of the baked bounding sphere, in the model's object space (the pivot offset).
	/// </summary>
	[Property, ReadOnly] public Vector3 Center { get; set; }

	/// <summary>
	/// Radius of the baked bounding sphere, in object space units.
	/// </summary>
	[Property, ReadOnly] public float Radius { get; set; } = 1.0f;

	/// <summary>
	/// The source model's render bounds at bake time.
	/// </summary>
	[Property, ReadOnly] public BBox Bounds { get; set; }

	/// <summary>
	/// How much alpha the baker had to add back at mip level 2 to keep thin parts covered (1 = none). High values mean
	/// twigs, spindles or wire mesh that the card keeps only from further away; the scene optimiser warns about them.
	/// </summary>
	[Property, ReadOnly] public float ThinDetail { get; set; } = 1.0f;

	/// <summary>
	/// Quality level the bake was made at (Low, Medium, High, Ultra or Custom).
	/// </summary>
	[Property, ReadOnly] public string QualityLevel { get; set; }

	/// <summary>
	/// Nearest distance, in bounding radii, from which the baker measured the card matching its model: at most 15% of
	/// pixels clearly different from there out to 16 radii. 0 when not measured, -1 when it never got there.
	/// <see cref="FlatpackRenderer"/>'s automatic swap distance never goes closer than this.
	/// </summary>
	[Property, ReadOnly] public float HoldsUpFrom { get; set; }

	/// <summary>
	/// Share of the covered texels that are only partly covered (0 to 1). Above <c>0.4</c> the bake is treated as
	/// see-through detail (wire mesh, twigs, nets) and drawn with a lowered alpha cut the baker fits to the model.
	/// </summary>
	[Property, ReadOnly] public float SeeThrough { get; set; }

	Model _cardModel;
	Material _cardMaterial;

	/// <summary>
	/// The shared card model for this impostor: one quad with the impostor material. Built on first use and reused
	/// by every <see cref="FlatpackRenderer"/> that points here, so identical cards can be instanced by the engine.
	/// </summary>
	[JsonIgnore, Hide]
	public Model CardModel
	{
		get
		{
			if ( _cardModel is not null && _cardMaterial == Material )
				return _cardModel;

			_cardMaterial = Material;
			_cardModel = FlatpackCard.Build( this );
			return _cardModel;
		}
	}

	Texture[] _atlases;
	Material _atlasMaterial;

	/// <summary>
	/// Tell texture streaming this impostor's atlases are on screen and needed at full size.
	/// </summary>
	/// <remarks>
	/// The streamer can't judge the card's real texel density: the card shows one frame (1/GridSize of the atlas)
	/// across the whole quad, and the shader picks UVs itself. Left alone it kept a 2048 atlas at 512 or 256 and the
	/// impostor looked blurred. <see cref="FlatpackSystem"/> calls this every frame for impostors with a visible card.
	/// </remarks>
	public void MarkAtlasesUsed()
	{
		if ( Material is null )
			return;

		if ( _atlases is null || _atlasMaterial != Material )
		{
			_atlasMaterial = Material;
			// Bakes from before iteration 8 use the combined normal and depth atlas; later ones the separate pair.
			_atlases = new[] { "g_tFlatpackAlbedo", "g_tFlatpackNormalDepth", "g_tFlatpackNormal", "g_tFlatpackDepth", "g_tFlatpackDepth8", "g_tFlatpackDepthMin", "g_tFlatpackFrames", "g_tFlatpackRoughnessAo" }
				.Select( Material.GetTexture )
				.Where( t => t is not null )
				.ToArray();
		}

		foreach ( var texture in _atlases )
			texture.MarkUsed( AtlasSize );
	}

	protected override void PostReload()
	{
		base.PostReload();

		// Rebuild the card next time it's asked for, in case a re-bake changed the bounds.
		_cardModel = null;
		_atlases = null;
	}
}
anakin.flatpack / FlatpackRenderer.cs
Game library
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Flatpack;

/// <summary>
/// One mesh LOD in a <see cref="FlatpackRenderer"/> chain: a model and the camera distance it takes over from.
/// </summary>
public sealed class FlatpackLodLevel
{
	/// <summary>
	/// The model to show in this band. Usually a lower-poly version of the source.
	/// </summary>
	[Property] public Model Model { get; set; }

	/// <summary>
	/// Camera distance where this level replaces the one before it, in world units.
	/// </summary>
	[Property, Range( 0, 20000 )] public float Distance { get; set; } = 1000.0f;
}

/// <summary>
/// Shows a model up close and its Flatpack impostor card far away.
/// </summary>
/// <remarks>
/// <para>
/// Put this next to a <see cref="ModelRenderer"/> and assign a baked <see cref="FlatpackImpostor"/>. The renderer
/// doesn't tick on its own: <see cref="FlatpackSystem"/> checks distances for every renderer in one batched,
/// time-sliced pass, so thousands of instances cost one loop instead of thousands of updates.
/// </para>
/// <para>
/// All cards made from the same impostor share one model and material, so the engine can instance them.
/// </para>
/// <para>
/// LODs: the source <c>.vmdl</c>'s own LODs keep working up close, because this component never touches the
/// <see cref="ModelRenderer"/> until the swap. For separate model files per detail level, fill <see cref="Levels"/>:
/// the renderer swaps the source's model by distance (with the same hysteresis), then hands over to the card past
/// the swap distance. With no <see cref="ModelRenderer"/> at all the card shows at every distance.
/// </para>
/// <para>
/// Alternative without this component: make the card the last LOD of the <c>.vmdl</c> (see <see cref="FlatpackImpostor"/>).
/// That gives you engine LOD switching for free, but no hysteresis and no cross-fade.
/// </para>
/// </remarks>
[Title( "Flatpack Renderer" ), Category( "Rendering" ), Icon( "view_in_ar" )]
public sealed class FlatpackRenderer : Component, Component.ExecuteInEditor
{
	/// <summary>
	/// What to draw.
	/// </summary>
	public enum DisplayMode
	{
		/// <summary>Model inside <see cref="EffectiveSwapDistance"/>, card beyond it.</summary>
		Auto,
		/// <summary>Always the real model.</summary>
		ForceModel,
		/// <summary>Always the card. Useful for checking a bake.</summary>
		ForceCard,
	}

	/// <summary>
	/// The baked impostor to show at distance. Left empty, the renderer uses any baked impostor whose source model
	/// matches the <see cref="Source"/> renderer's model, so adding the component after a bake needs no setup.
	/// </summary>
	[Property] public FlatpackImpostor Impostor { get; set; }

	/// <summary>
	/// The renderer for the real model. Found on this GameObject if left empty.
	/// </summary>
	[Property] public ModelRenderer Source { get; set; }

	/// <summary>
	/// Pick the swap distance from the object's size: the card takes over once the bounding sphere is about
	/// 1/<see cref="AutoDistanceFactor"/> of the distance across, roughly 5% of a 1080p screen's height at 70 degrees FOV.
	/// </summary>
	[Property] public bool AutoSwapDistance { get; set; } = true;

	/// <summary>
	/// Auto mode swaps as soon as the card looks like the model: at the distance the baker measured
	/// (<see cref="FlatpackImpostor.HoldsUpFrom"/>, calibrated by eye), never closer than <see cref="MinimumSwapRadii"/>.
	/// Measured in iteration 6: a 534,000-triangle tree forest drew in 7 to 13 ms swapping at 8 radii against 25 to 28 ms
	/// at 30. Turn it off to swap at <see cref="AutoDistanceFactor"/> instead (bakes that weren't measured always do).
	/// </summary>
	[Property, ShowIf( nameof( AutoSwapDistance ), true )] public bool SwapWhenItLooksRight { get; set; } = true;

	/// <summary>
	/// The closest an early swap may happen, in bounding radii. Up close a card covers many pixels and costs more to
	/// draw, so very light models can end up slower as cards than as models.
	/// </summary>
	[Property, Range( 2, 30 ), ShowIf( nameof( SwapWhenItLooksRight ), true )] public float MinimumSwapRadii { get; set; } = 6.0f;

	/// <summary>
	/// Auto mode swaps at this many bounding-sphere radii from the camera when it isn't swapping early (or the bake
	/// wasn't measured), or where the bake holds up if that's further.
	/// </summary>
	[Property, Range( 5, 100 ), ShowIf( nameof( AutoSwapDistance ), true )] public float AutoDistanceFactor { get; set; } = 30.0f;

	/// <summary>
	/// Camera distance where the card takes over, in world units, when <see cref="AutoSwapDistance"/> is off.
	/// </summary>
	[Property, Range( 0, 20000 ), ShowIf( nameof( AutoSwapDistance ), false )] public float SwapDistance { get; set; } = 2000.0f;

	/// <summary>
	/// The swap distance in use: <see cref="SwapDistance"/>, or the automatic one, then scaled by the global
	/// <see cref="FlatpackSystem.Level"/> (a game's graphics setting).
	/// </summary>
	public float EffectiveSwapDistance
	{
		get
		{
			var (levelScale, levelMinimum) = FlatpackSystem.LevelScaling;
			if ( float.IsPositiveInfinity( levelScale ) )
				return float.PositiveInfinity;

			if ( !AutoSwapDistance || Impostor is null )
				return SwapDistance * levelScale;

			var scale = WorldScale;
			var radius = Impostor.Radius * MathF.Max( scale.x, MathF.Max( scale.y, scale.z ) );
			var measured = Impostor.HoldsUpFrom > 0.0f;
			var factor = SwapWhenItLooksRight && measured
				? MathF.Max( Impostor.HoldsUpFrom, MinimumSwapRadii )
				: MathF.Max( AutoDistanceFactor, Impostor.HoldsUpFrom );

			// A lower graphics level swaps earlier, but never closer than its own minimum.
			factor *= levelScale;
			if ( levelMinimum > 0.0f )
				factor = MathF.Max( factor, levelMinimum );
			return radius * factor;
		}
	}

	/// <summary>
	/// Width of the dead band around <see cref="SwapDistance"/>. The card appears past SwapDistance + Hysteresis / 2
	/// and the model returns inside SwapDistance - Hysteresis / 2, so a camera hovering at the boundary doesn't flicker.
	/// </summary>
	[Property, Range( 0, 2000 )] public float Hysteresis { get; set; } = 200.0f;

	/// <summary>
	/// Dither the card in and out over <see cref="FadeTime"/> instead of popping. The model stays visible under the
	/// card until the fade-in finishes, so the swap reads as a dissolve.
	/// </summary>
	[Property] public bool CrossFade { get; set; } = true;

	/// <summary>
	/// Seconds a cross-fade takes.
	/// </summary>
	[Property, Range( 0, 2 ), ShowIf( nameof( CrossFade ), true )] public float FadeTime { get; set; } = 0.35f;

	/// <summary>
	/// Override the distance check.
	/// </summary>
	[Property] public DisplayMode Mode { get; set; } = DisplayMode.Auto;

	/// <summary>
	/// Optional mesh LODs between the source model and the card, nearest first. Each replaces the source renderer's
	/// model past its <see cref="FlatpackLodLevel.Distance"/>. Distances past <see cref="SwapDistance"/> never show.
	/// </summary>
	[Property] public List<FlatpackLodLevel> Levels { get; set; } = new();

	/// <summary>
	/// Index into <see cref="Levels"/> currently on the source renderer, or -1 for the source's own model.
	/// </summary>
	public int CurrentLevel { get; private set; } = -1;

	/// <summary>
	/// True while the card is the visible representation (including while it fades in).
	/// </summary>
	public bool ShowingCard { get; private set; }

	SceneObject _card;
	Model _baseModel;      // the source renderer's own model, restored when the chain steps back to level -1
	FlatpackImpostor _countedImpostor;   // impostor this renderer's visible card is counted against in the system
	float _fade;           // 0 = card hidden, 1 = card fully shown
	float _fadeTarget;
	internal int SystemIndex = -1;

	protected override void OnEnabled()
	{
		Source ??= GameObject.Components.Get<ModelRenderer>();
		_baseModel = Source.IsValid() ? Source.Model : null;
		Impostor ??= FindImpostorFor( _baseModel );
		CurrentLevel = -1;
		Transform.OnTransformChanged += SyncTransform;

		Scene.GetSystem<FlatpackSystem>()?.Register( this );
	}

	protected override void OnDisabled()
	{
		Transform.OnTransformChanged -= SyncTransform;
		Scene.GetSystem<FlatpackSystem>()?.Unregister( this );

		DestroyCard();
		SetLevel( -1 );
		SetSourceVisible( true );
		ShowingCard = false;
		_fade = _fadeTarget = 0.0f;
	}

	protected override void OnValidate()
	{
		// An edited impostor or a changed mode should show up straight away in the editor.
		DestroyCard();
		SetLevel( -1 );
		ShowingCard = false;
		_fade = _fadeTarget = 0.0f;
		SetSourceVisible( true );
	}

	/// <summary>
	/// Called by <see cref="FlatpackSystem"/> with the squared distance from the camera to this object.
	/// Decides which representation should be showing; the fade itself runs in <see cref="TickFade"/>.
	/// </summary>
	internal bool Evaluate( float distanceSquared )
	{
		UpdateLevel( distanceSquared );

		var wantCard = Mode switch
		{
			DisplayMode.ForceCard => true,
			DisplayMode.ForceModel => false,
			_ => WantCardAt( distanceSquared ),
		};

		// Card-only setup: nothing else to show.
		if ( !Source.IsValid() && Mode != DisplayMode.ForceModel )
			wantCard = true;

		if ( wantCard && Impostor?.Material is null )
			wantCard = false;

		if ( wantCard == ShowingCard )
			return false;

		ShowingCard = wantCard;
		_fadeTarget = wantCard ? 1.0f : 0.0f;

		if ( wantCard )
			EnsureCard();

		if ( !CrossFade || FadeTime <= 0.0f )
			_fade = _fadeTarget;

		Apply();

		// The system keeps calling TickFade while this returns true.
		return _fade != _fadeTarget;
	}

	/// <summary>
	/// Step the mesh LOD chain toward the level for this distance, one band edge at a time, each with the same
	/// hysteresis band as the card swap.
	/// </summary>
	void UpdateLevel( float distanceSquared )
	{
		if ( Levels is null || Levels.Count == 0 || !Source.IsValid() || Mode != DisplayMode.Auto )
			return;

		var level = Math.Min( CurrentLevel, Levels.Count - 1 );
		var half = Hysteresis * 0.5f;

		while ( level + 1 < Levels.Count && Beyond( distanceSquared, Levels[level + 1].Distance + half ) )
			level++;

		while ( level >= 0 && !Beyond( distanceSquared, Levels[level].Distance - half ) )
			level--;

		SetLevel( level );
	}

	static bool Beyond( float distanceSquared, float distance )
	{
		distance = MathF.Max( distance, 0.0f );
		return distanceSquared > distance * distance;
	}

	void SetLevel( int level )
	{
		if ( level == CurrentLevel || !Source.IsValid() )
			return;

		CurrentLevel = level;
		var model = level >= 0 ? Levels[level].Model : _baseModel;
		if ( model is not null )
			Source.Model = model;

		// A new model can mean a new scene object, which starts visible.
		SetSourceVisible( !ShowingCard || _fade < 1.0f );
	}

	/// <summary>
	/// The first baked impostor whose source is <paramref name="model"/>, or null.
	/// </summary>
	public static FlatpackImpostor FindImpostorFor( Model model )
	{
		if ( model is null )
			return null;

		return ResourceLibrary.GetAll<FlatpackImpostor>()
			.FirstOrDefault( x => x.SourceModel is not null && x.SourceModel.ResourcePath == model.ResourcePath );
	}

	bool WantCardAt( float distanceSquared )
	{
		// Hysteresis: the threshold depends on which side we're already on.
		var swap = EffectiveSwapDistance;
		var threshold = ShowingCard
			? swap - Hysteresis * 0.5f
			: swap + Hysteresis * 0.5f;

		threshold = MathF.Max( threshold, 0.0f );
		return distanceSquared > threshold * threshold;
	}

	/// <summary>
	/// Advances a running cross-fade. Returns false when it has finished.
	/// </summary>
	internal bool TickFade( float delta )
	{
		var step = FadeTime > 0.0f ? delta / FadeTime : 1.0f;
		_fade = _fadeTarget > _fade
			? MathF.Min( _fade + step, _fadeTarget )
			: MathF.Max( _fade - step, _fadeTarget );

		Apply();
		return _fade != _fadeTarget;
	}

	void Apply()
	{
		if ( _card.IsValid() )
		{
			_card.RenderingEnabled = _fade > 0.0f;
			CountCard( _fade > 0.0f ? Impostor : null );

			// 1 draws every pixel; anything lower dithers the card out (see flatpack_impostor.shader).
			_card.Attributes.Set( "FlatpackFade", _fade );
		}

		// The model stays up until the card has fully faded in, and comes back the moment we head toward it.
		SetSourceVisible( !ShowingCard || _fade < 1.0f );
	}

	void SetSourceVisible( bool visible )
	{
		if ( Source.IsValid() && Source.SceneObject.IsValid() )
			Source.SceneObject.RenderingEnabled = visible && Source.Enabled;
	}

	void EnsureCard()
	{
		if ( _card.IsValid() || Impostor is null )
			return;

		_card = new SceneObject( Scene.SceneWorld, Impostor.CardModel, WorldTransform );
		_card.Tags.SetFrom( GameObject.Tags );
		_card.RenderingEnabled = false;
	}

	/// <summary>
	/// Keep <see cref="FlatpackSystem"/>'s count of visible cards per impostor current, so it streams their atlases in.
	/// </summary>
	void CountCard( FlatpackImpostor visibleImpostor )
	{
		if ( visibleImpostor == _countedImpostor )
			return;

		var system = Scene?.GetSystem<FlatpackSystem>();
		system?.CardVisibilityChanged( _countedImpostor, false );
		system?.CardVisibilityChanged( visibleImpostor, true );
		_countedImpostor = visibleImpostor;
	}

	void DestroyCard()
	{
		CountCard( null );
		_card?.Delete();
		_card = null;
	}

	void SyncTransform()
	{
		if ( _card.IsValid() )
			_card.Transform = WorldTransform;
	}
}
anakin.flatpack / Editor/FlatpackBakeSettings.cs
Editor library
using Sandbox;

namespace Flatpack.EditorTools;

/// <summary>
/// Atlas sizes the baker offers.
/// </summary>
public enum FlatpackAtlasSize
{
	[Title( "512" )] Size512 = 512,
	[Title( "1024" )] Size1024 = 1024,
	[Title( "2048" )] Size2048 = 2048,
	[Title( "3072" )] Size3072 = 3072,
	[Title( "4096" )] Size4096 = 4096,
}

/// <summary>
/// Quality levels for a bake. Each one sets the number of views, the atlas size and the depth search steps, picked
/// from the iteration 4 measurements (see <c>Benchmarks/iteration-4-quality-levels</c>).
/// </summary>
public enum FlatpackQuality
{
	[Title( "Low" ), Description( "12 x 12 views, 1024 atlas, 8 search steps. About 5 MB. For props you only see from far away (past about 16 bounding radii)." )]
	Low,

	[Title( "Medium" ), Description( "12 x 12 views, 2048 atlas, 16 search steps. About 19 MB. The default: holds up from about 4 to 6 bounding radii." )]
	Medium,

	[Title( "High" ), Description( "24 x 24 views, 4096 atlas, 16 search steps. About 75 MB. Same as Medium up close, closer to the real model past 8 bounding radii." )]
	High,

	[Title( "Ultra" ), Description( "12 x 12 views, 3072 atlas, 32 search steps. About 42 MB and about 1.5x the GPU cost of Medium. The sharpest level up close: for props that swap near the camera." )]
	Ultra,

	[Title( "Auto" ), Description( "Bake at Low, measure the card against the model at the target distance, and step up a level until it holds up. Slower to bake; saves memory on models that look fine at a lower level." )]
	Auto,

	[Title( "Custom" ), Description( "Set views, atlas size and search steps yourself." )]
	Custom,
}

/// <summary>
/// Everything the baker needs to know besides the model. Shown in the bake window with a control sheet.
/// </summary>
public sealed class FlatpackBakeSettings
{
	/// <summary>
	/// How good the impostor looks, against memory and GPU cost. Pick Custom to set the numbers yourself.
	/// </summary>
	[Property] public FlatpackQuality Quality { get; set; } = FlatpackQuality.Medium;

	/// <summary>
	/// For Auto: the distance, in bounding radii, where the card has to match the model (from there outward). Set it to
	/// the swap distance you'll use; the Flatpack Renderer's automatic one is 30.
	/// </summary>
	[Property, Range( 2, 60 ), Step( 1 ), ShowIf( nameof( Quality ), FlatpackQuality.Auto )] public float AutoTargetRadii { get; set; } = 30.0f;

	/// <summary>
	/// Measure the finished bake against the model (a second or two) and store where it holds up. The Flatpack Renderer
	/// then never swaps closer than that, and the inspector shows it.
	/// </summary>
	[Property] public bool MeasureQuality { get; set; } = true;

	/// <summary>
	/// Frames per atlas side. 12 gives 144 views, 15 degrees apart on the hemisphere. 8 ghosts visibly; past 12, more
	/// views only help a little unless the atlas grows with them. Used when Quality is Custom.
	/// </summary>
	[Property, Range( 4, 32 ), Step( 1 ), ShowIf( nameof( Quality ), FlatpackQuality.Custom )] public int GridSize { get; set; } = 12;

	/// <summary>
	/// Atlas width and height. Each frame gets AtlasSize / GridSize pixels. Used when Quality is Custom.
	/// </summary>
	[Property, ShowIf( nameof( Quality ), FlatpackQuality.Custom )] public FlatpackAtlasSize AtlasSize { get; set; } = FlatpackAtlasSize.Size2048;

	/// <summary>
	/// Depth search steps per frame, written into the material (you can change it there later). The biggest single
	/// lever on both quality and GPU cost: 8 ghosts thin parts, 32 costs about 1.5x of 16. Used when Quality is Custom.
	/// </summary>
	[Property, Range( 0, 32 ), Step( 1 ), ShowIf( nameof( Quality ), FlatpackQuality.Custom )] public int SearchSteps { get; set; } = 16;

	/// <summary>
	/// The views, atlas size and search steps this bake will use: the quality level's, or the custom values.
	/// </summary>
	public (int GridSize, FlatpackAtlasSize AtlasSize, int SearchSteps) Resolve() => Quality switch
	{
		FlatpackQuality.Low => (12, FlatpackAtlasSize.Size1024, 8),
		FlatpackQuality.Medium => (12, FlatpackAtlasSize.Size2048, 16),
		FlatpackQuality.High => (24, FlatpackAtlasSize.Size4096, 16),
		FlatpackQuality.Ultra => (12, FlatpackAtlasSize.Size3072, 32),
		FlatpackQuality.Auto => (12, FlatpackAtlasSize.Size1024, 8),
		_ => (GridSize, AtlasSize, SearchSteps),
	};

	/// <summary>
	/// A copy of these settings at another quality level.
	/// </summary>
	public FlatpackBakeSettings WithQuality( FlatpackQuality quality )
	{
		var copy = (FlatpackBakeSettings)MemberwiseClone();
		copy.Quality = quality;
		return copy;
	}

	/// <summary>
	/// Which view directions get frames.
	/// </summary>
	[Property] public FlatpackLayout Layout { get; set; } = FlatpackLayout.Hemisphere;

	/// <summary>
	/// Turn the card around the object's up axis only, instead of facing the camera fully.
	/// Always on for <see cref="FlatpackLayout.Cylinder"/>.
	/// </summary>
	[Property] public bool ZLockedCard { get; set; }

	/// <summary>
	/// Also bake roughness and ambient occlusion. Without it the impostor uses roughness 1 and no AO.
	/// </summary>
	[Property] public bool BakeRoughnessAo { get; set; } = true;

	/// <summary>
	/// Also bake how much sky each part of the model sees (lit from eight directions, shadowed against unshadowed).
	/// The impostor material's Sky Occlusion slider then darkens enclosed parts, which helps dense foliage keep its dark
	/// interior. Off by default: the real model gets no such occlusion in-engine, so the card matches it better without.
	/// Needs <see cref="BakeRoughnessAo"/>; roughly triples bake time.
	/// </summary>
	[Property, ShowIf( nameof( BakeRoughnessAo ), true )] public bool BakeSelfShadow { get; set; }

	/// <summary>
	/// Render each view this many times larger and average it down. Parts thinner than a texel (wire mesh, twigs) then
	/// bake as partial coverage instead of filling in or vanishing. 2 measured 2 to 3 points fewer wrong pixels up close
	/// for about a third more bake time; 4 adds little. Fine wire mesh (fences) gets worse either way.
	/// </summary>
	[Property, Range( 1, 4 ), Step( 1 )] public int Supersample { get; set; } = 2;

	/// <summary>
	/// How many texels to grow colour out past the silhouette, inside each frame, so mipmaps don't pull in
	/// the transparent background as a dark halo. 0 fills the whole frame.
	/// </summary>
	[Property, Range( 0, 64 ), Step( 1 )] public int EdgePadding { get; set; } = 16;

	/// <summary>
	/// Frame every view to the model's own outline and depth in that view instead of its whole bounding sphere. A tall
	/// statue fills its views instead of a third of them, and the depth spans only the surfaces the view sees, so the
	/// same atlas holds more texels and finer depth on the object.
	/// </summary>
	[Property] public bool FitEachView { get; set; } = true;

	/// <summary>
	/// Bits of depth per texel: 8 (BC4, half a byte a texel) or 16 (R16, 2 bytes). Measured in iteration 8 on the test set
	/// at Medium: 16 bits had 2.3 points fewer wrong pixels at 2 radii and 1.6 at 4, for more video memory (about
	/// 27 MB against 19 at Medium, 60 against 42 at Ultra).
	/// </summary>
	[Property, Range( 8, 16 ), Step( 8 )] public int DepthBits { get; set; } = 8;

	/// <summary>
	/// Folder for the outputs, relative to the project's Assets folder (for example <c>flatpack/trees</c>).
	/// Empty puts them next to the source model, or in <c>flatpack/</c> when the source isn't in this project.
	/// </summary>
	[Property] public string OutputFolder { get; set; } = "";
}
anakin.flatpack / Code/FlatpackMath.cs
Game library
using System;
using System.Collections.Generic;
using Sandbox;

namespace Flatpack;

/// <summary>
/// How the baked views ("frames") are spread over the directions an impostor can be seen from.
/// </summary>
public enum FlatpackLayout
{
	/// <summary>
	/// Hemi-octahedral: the upper half of the sphere. Best for anything that stands on the ground and is never
	/// seen from below (trees, rocks, props). All N x N frames go to the half you actually see.
	/// </summary>
	Hemisphere = 0,

	/// <summary>
	/// Full octahedral: every direction. For floating or tumbling objects that can be seen from below.
	/// </summary>
	Sphere = 1,

	/// <summary>
	/// All N x N frames on the horizon, evenly around Z, and the card only turns around Z. Highest yaw
	/// resolution, but it can't show the top of the object. Classic tree billboard behaviour.
	/// </summary>
	Cylinder = 2,
}

/// <summary>
/// The frame layout maths shared by the baker and the runtime. <c>flatpack_impostor.shader</c> has an HLSL
/// copy of the same functions; they must stay identical or the shader samples the wrong frame.
/// </summary>
/// <remarks>
/// Coordinates are Source/s&amp;box: +X forward, +Y left, +Z up. A frame direction points from the object
/// toward the camera that captured it.
/// </remarks>
public static class FlatpackMath
{
	/// <summary>
	/// Maps a unit direction to the [-1, 1] square for the given layout (not used by <see cref="FlatpackLayout.Cylinder"/>).
	/// </summary>
	public static Vector2 EncodeOct( Vector3 d, FlatpackLayout layout )
	{
		if ( layout == FlatpackLayout.Hemisphere )
		{
			// Nothing below the horizon was baked, so clamp onto the horizon ring.
			d.z = MathF.Max( d.z, 0.0f );
			d = (d + new Vector3( 0, 0, 1e-5f )).Normal;
			var px = d.x / (MathF.Abs( d.x ) + MathF.Abs( d.y ) + d.z);
			var py = d.y / (MathF.Abs( d.x ) + MathF.Abs( d.y ) + d.z);

			// Rotate the octahedron's top half by 45 degrees so it fills the whole square.
			return new Vector2( px + py, px - py );
		}

		var sum = MathF.Abs( d.x ) + MathF.Abs( d.y ) + MathF.Abs( d.z );
		var p = new Vector2( d.x / sum, d.y / sum );
		if ( d.z < 0.0f )
		{
			// Fold the lower half out into the square's corners.
			p = new Vector2( (1.0f - MathF.Abs( p.y )) * Sign( p.x ), (1.0f - MathF.Abs( p.x )) * Sign( p.y ) );
		}

		return p;
	}

	/// <summary>
	/// Inverse of <see cref="EncodeOct"/>. Returns a unit direction.
	/// </summary>
	public static Vector3 DecodeOct( Vector2 o, FlatpackLayout layout )
	{
		if ( layout == FlatpackLayout.Hemisphere )
		{
			var px = (o.x + o.y) * 0.5f;
			var py = (o.x - o.y) * 0.5f;
			return new Vector3( px, py, 1.0f - MathF.Abs( px ) - MathF.Abs( py ) ).Normal;
		}

		var d = new Vector3( o.x, o.y, 1.0f - MathF.Abs( o.x ) - MathF.Abs( o.y ) );
		if ( d.z < 0.0f )
		{
			d.x = (1.0f - MathF.Abs( o.y )) * Sign( o.x );
			d.y = (1.0f - MathF.Abs( o.x )) * Sign( o.y );
		}

		return d.Normal;
	}

	/// <summary>
	/// The direction frame (<paramref name="x"/>, <paramref name="y"/>) of an <paramref name="n"/> x <paramref name="n"/> grid is baked from.
	/// </summary>
	public static Vector3 FrameDirection( int x, int y, int n, FlatpackLayout layout )
	{
		if ( layout == FlatpackLayout.Cylinder )
		{
			var index = y * n + x;
			var yaw = index / (float)(n * n) * MathF.Tau;
			return new Vector3( MathF.Cos( yaw ), MathF.Sin( yaw ), 0.0f );
		}

		// Frame centres sit on the grid vertices (not cell centres), so the outer frames land exactly on the
		// horizon and the shader can blend between them without extrapolating.
		var o = new Vector2( x, y ) / (n - 1) * 2.0f - 1.0f;
		return DecodeOct( o, layout );
	}

	/// <summary>
	/// The camera basis a frame is captured with. Right is image +X, up is image -Y.
	/// </summary>
	public static void FrameBasis( Vector3 d, out Vector3 right, out Vector3 up )
	{
		var refUp = MathF.Abs( d.z ) < 0.999f ? Vector3.Up : Vector3.Forward;
		right = Vector3.Cross( -d, refUp ).Normal;
		up = Vector3.Cross( right, -d );
	}

	/// <summary>
	/// Camera rotation for a frame. Equivalent to <see cref="FrameBasis"/> (checked in the editor: <c>Rotation.LookAt</c>
	/// produces the same right and up vectors).
	/// </summary>
	public static Rotation FrameRotation( Vector3 d )
	{
		FrameBasis( d, out _, out var up );
		return Rotation.LookAt( -d, up );
	}

	/// <summary>
	/// Smallest sphere-ish bound we bake around: the box centre and its half diagonal. Not the tightest sphere,
	/// but stable and cheap, and the shader only needs the frames to contain the object.
	/// </summary>
	public static (Vector3 Center, float Radius) BoundingSphere( BBox bounds )
	{
		return (bounds.Center, MathF.Max( bounds.Size.Length * 0.5f, 0.01f ));
	}

	/// <summary>
	/// A bounding sphere fitted to points (Ritter's method): pick two far-apart points, then grow the sphere for any point
	/// left outside. Within a few percent of the minimal sphere and never smaller than needed.
	/// </summary>
	public static (Vector3 Center, float Radius) BoundingSphere( IReadOnlyList<Vector3> points )
	{
		if ( points is null || points.Count == 0 )
			return (Vector3.Zero, 0.01f);

		var a = points[0];
		var b = Farthest( points, a );
		var c = Farthest( points, b );

		var center = (b + c) * 0.5f;
		var radius = b.Distance( c ) * 0.5f;

		foreach ( var p in points )
		{
			var d = p.Distance( center );
			if ( d <= radius )
				continue;

			// Move the centre toward p just enough that the new sphere still touches the old one's far side.
			var newRadius = (radius + d) * 0.5f;
			center += (p - center) * ((newRadius - radius) / d);
			radius = newRadius;
		}

		// A hair of slack so silhouette texels on the sphere's rim aren't clipped by the frame edge.
		return (center, MathF.Max( radius * 1.01f, 0.01f ));
	}

	/// <summary>
	/// Metres in one world unit. s&amp;box units are inches, as in Source; the iteration 6 distance sheet ("6 radii (21 m)"
	/// for a tree 140 units in radius) used the same conversion.
	/// </summary>
	public const float MetresPerUnit = 0.0254f;

	/// <summary>A distance in world units, in metres.</summary>
	public static float ToMetres( float units ) => units * MetresPerUnit;

	/// <summary>
	/// A distance in world units as a reader wants it next to bounding radii: one decimal under 10 m, whole metres from
	/// there ("2.5 m", "21 m"). The inspector, the bake check summary and the benchmark sheets all use this.
	/// </summary>
	public static string FormatMetres( float units )
	{
		var metres = ToMetres( units );
		return metres < 10.0f
			? $"{metres.ToString( "0.0", System.Globalization.CultureInfo.InvariantCulture )} m"
			: $"{metres.ToString( "0", System.Globalization.CultureInfo.InvariantCulture )} m";
	}

	/// <summary>
	/// Columns the frame lookup texture reserves for image rectangles; the depth ranges sit this far to the right.
	/// The shader's <c>FlatpackFrameFit</c> reads texel (x, y) and (x + 32, y).
	/// </summary>
	public const int FrameFitColumns = 32;

	/// <summary>
	/// The bounds one frame was baked with: where the object sits inside the bounding sphere's frame, and how deep.
	/// </summary>
	public struct FrameFit
	{
		/// <summary>Top-left corner of the frame's image rectangle, in the sphere-sized frame's 0..1 image coordinates (v down).</summary>
		public Vector2 Min;

		/// <summary>Bottom-right corner of the image rectangle.</summary>
		public Vector2 Max;

		/// <summary>Height of the nearest surface along the frame direction (toward its camera), in bounding radii.</summary>
		public float Near;

		/// <summary>Height of the furthest surface, in bounding radii. Always below <see cref="Near"/>.</summary>
		public float Far;

		/// <summary>The whole bounding sphere: what every frame used before iteration 8.</summary>
		public static FrameFit Sphere => new() { Min = Vector2.Zero, Max = Vector2.One, Near = 1.0f, Far = -1.0f };
	}

	/// <summary>
	/// Fit one frame to the points it sees: their image rectangle, grown by <paramref name="padTexels"/> of a
	/// <paramref name="cellTexels"/>-wide frame on every side, and their depth range. Rounded outward to the 8-bit steps the
	/// lookup texture stores, so the baker renders with exactly the bounds the shader reads back.
	/// </summary>
	public static FrameFit FitFrame( IReadOnlyList<Vector3> points, Vector3 center, float radius, Vector3 dir, int cellTexels, int padTexels )
	{
		if ( points is null || points.Count == 0 )
			return FrameFit.Sphere;

		FrameBasis( dir, out var right, out var up );
		float minU = float.MaxValue, maxU = float.MinValue, minV = float.MaxValue, maxV = float.MinValue;
		float near = float.MinValue, far = float.MaxValue;

		foreach ( var p in points )
		{
			var rel = p - center;
			var u = 0.5f + Vector3.Dot( rel, right ) / (2.0f * radius);
			var v = 0.5f - Vector3.Dot( rel, up ) / (2.0f * radius);
			var h = Vector3.Dot( rel, dir ) / radius;
			minU = MathF.Min( minU, u ); maxU = MathF.Max( maxU, u );
			minV = MathF.Min( minV, v ); maxV = MathF.Max( maxV, v );
			near = MathF.Max( near, h ); far = MathF.Min( far, h );
		}

		// Padding: the rectangle maps onto cellTexels texels, padTexels of which on each side stay outside the object.
		var grow = cellTexels > 2 * padTexels + 1 ? cellTexels / (float)(cellTexels - 2 * padTexels) : 1.0f;
		var padU = (maxU - minU) * (grow - 1.0f) * 0.5f;
		var padV = (maxV - minV) * (grow - 1.0f) * 0.5f;

		var fit = new FrameFit
		{
			Min = new Vector2( minU - padU, minV - padV ),
			Max = new Vector2( maxU + padU, maxV + padV ),
			Near = near,
			Far = far,
		};
		return DecodeFrameFit( EncodeFrameFit( fit ) );
	}

	/// <summary>
	/// The two lookup texels for a frame: (left, top, right) and (bottom, near, far), each value one byte, rounded outward
	/// so the stored bounds always contain the real ones. Alpha stays 255 (the PNG path drops colour under low alpha).
	/// </summary>
	public static (Color32 Rect, Color32 Depth) EncodeFrameFit( FrameFit fit )
	{
		int Down( float v ) => Math.Clamp( (int)MathF.Floor( v * 255.0f + 1e-3f ), 0, 255 );
		int Up( float v ) => Math.Clamp( (int)MathF.Ceiling( v * 255.0f - 1e-3f ), 0, 255 );

		int minU = Down( fit.Min.x ), maxU = Up( fit.Max.x );
		int minV = Down( fit.Min.y ), maxV = Up( fit.Max.y );
		int near = Up( (fit.Near + 1.0f) * 0.5f ), far = Down( (fit.Far + 1.0f) * 0.5f );

		// Never let a frame collapse (a flat panel seen edge-on, a plane seen face-on): keep at least two steps each way.
		(minU, maxU) = Widen( minU, maxU );
		(minV, maxV) = Widen( minV, maxV );
		(far, near) = Widen( far, near );

		return (new Color32( (byte)minU, (byte)minV, (byte)maxU, 255 ), new Color32( (byte)maxV, (byte)near, (byte)far, 255 ));
	}

	/// <summary>
	/// The bounds two lookup texels describe. Matches the shader's <c>FlatpackFrameFit</c>.
	/// </summary>
	public static FrameFit DecodeFrameFit( (Color32 Rect, Color32 Depth) texels ) => new()
	{
		Min = new Vector2( texels.Rect.r / 255.0f, texels.Rect.g / 255.0f ),
		Max = new Vector2( texels.Rect.b / 255.0f, texels.Depth.r / 255.0f ),
		Near = texels.Depth.g / 255.0f * 2.0f - 1.0f,
		Far = texels.Depth.b / 255.0f * 2.0f - 1.0f,
	};

	static (int Low, int High) Widen( int low, int high )
	{
		while ( high - low < 2 )
		{
			if ( high < 255 ) high++;
			if ( high - low < 2 && low > 0 ) low--;
		}
		return (low, high);
	}

	static Vector3 Farthest( IReadOnlyList<Vector3> points, Vector3 from )
	{
		var best = from;
		var bestDistance = -1.0f;
		foreach ( var p in points )
		{
			var d = p.DistanceSquared( from );
			if ( d > bestDistance )
			{
				bestDistance = d;
				best = p;
			}
		}

		return best;
	}

	static float Sign( float v ) => v >= 0.0f ? 1.0f : -1.0f;
}
anakin.flatpack / Editor/FlatpackSceneOptimizer.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Editor;
using Sandbox;

namespace Flatpack.EditorTools;

/// <summary>
/// One window to Flatpack a whole scene: lists every model in it, recommends which ones are worth an impostor and why,
/// bakes what's missing and adds a <see cref="FlatpackRenderer"/> to every instance in one undoable step.
/// Open it from Editor > Flatpack > Optimize Scene.
/// </summary>
public sealed class FlatpackSceneOptimizer : Dialog
{
	/// <summary>
	/// How strongly the window suggests Flatpacking a model.
	/// </summary>
	public enum Advice
	{
		Recommended,
		LowGain,
		Skip,
		Done,
	}

	/// <summary>
	/// Everything the window knows about one model used in the scene.
	/// </summary>
	public sealed class Entry
	{
		public Model Model { get; init; }
		public List<ModelRenderer> Renderers { get; } = new();
		public int Triangles { get; init; }
		public int Flatpacked { get; set; }
		public FlatpackImpostor Impostor { get; set; }
		public Advice Advice { get; set; }
		public string Reason { get; set; }
		public bool Selected { get; set; }

		public int Instances => Renderers.Count;
		public long TotalTriangles => (long)Triangles * Instances;
	}

	/// <summary>
	/// Below this many triangles a card rarely beats the model: the card's pixel shader costs about the same as a cheap
	/// mesh (see Benchmarks/iteration-2: a 1,832-triangle plant broke even).
	/// </summary>
	public const int MinTriangles = 1500;

	/// <summary>
	/// A cheap model can still be worth it in bulk: this many triangles across all its instances.
	/// </summary>
	public const long MinTotalTriangles = 200_000;

	/// <summary>
	/// Above this per-mip alpha correction at mip 2, a bake has a lot of thin detail (twigs, wire mesh) that the card
	/// keeps only from further away. The model is still recommended, with a warning.
	/// </summary>
	public const float ThinDetailWarning = 1.25f;

	static FlatpackSceneOptimizer _current;

	readonly List<Entry> _entries = new();
	readonly ScrollArea _list;
	readonly Label _summary;
	readonly Label _status;
	readonly Button _apply;
	readonly Button _remove;
	CancellationTokenSource _cancel;
	Scene _scannedScene;

	[Menu( "Editor", "Flatpack/Optimize Scene" )]
	public static void Open()
	{
		if ( _current is not { IsValid: true } )
			_current = new FlatpackSceneOptimizer();

		_current.Scan();
		_current.Show();
	}

	FlatpackSceneOptimizer() : base( EditorWindow )
	{
		Window.Title = "Flatpack: Optimize Scene";
		Window.SetWindowIcon( "forest" );
		Window.Size = new Vector2( 860, 620 );
		Window.StateCookie = "FlatpackSceneOptimizer";

		Layout = Layout.Column();
		Layout.Margin = 12;
		Layout.Spacing = 8;

		var title = Layout.Add( new Label( "Every model in the open scene, with a recommendation for each." ) );
		title.SetStyles( "font-weight: bold;" );
		Layout.Add( new Label( "Ticked models get baked (if needed) and a Flatpack Renderer on every instance, as one step you can undo with Ctrl+Z." ) { WordWrap = true } );

		var tools = Layout.AddRow();
		tools.Spacing = 6;
		tools.Add( new Button( "Select Recommended", "auto_awesome" ) { Clicked = () => SelectWhere( e => e.Advice == Advice.Recommended ) } );
		tools.Add( new Button( "Select All", "select_all" ) { Clicked = () => SelectWhere( e => e.Advice != Advice.Done && e.Advice != Advice.Skip ) } );
		tools.Add( new Button( "Select None", "deselect" ) { Clicked = () => SelectWhere( _ => false ) } );
		tools.AddStretchCell();
		tools.Add( new Button( "Rescan", "refresh" ) { Clicked = Scan } );

		_list = Layout.Add( new ScrollArea( this ), 1 );
		_list.Canvas = new Widget( _list );
		_list.Canvas.Layout = Layout.Column();
		_list.Canvas.Layout.Spacing = 2;

		_summary = Layout.Add( new Label( "" ) { WordWrap = true } );
		_status = Layout.Add( new Label( "" ) { WordWrap = true } );

		// Quality for the bakes this window makes. Shares the bake window's saved settings, so both stay in step.
		var settings = EditorCookie.Get( "Flatpack.BakeSettings", new FlatpackBakeSettings() ) ?? new FlatpackBakeSettings();
		var serializedSettings = settings.GetSerialized();
		serializedSettings.OnPropertyChanged += _ =>
		{
			EditorCookie.Set( "Flatpack.BakeSettings", settings );
			UpdateSummary();
		};
		var quality = new ControlSheet();
		quality.AddObject( serializedSettings, p => p.Name == nameof( FlatpackBakeSettings.Quality ) );
		Layout.Add( quality );

		var footer = Layout.AddRow();
		footer.Spacing = 8;
		_remove = footer.Add( new Button( "Remove Flatpack from Scene", "undo" )
		{
			ToolTip = "Remove every Flatpack Renderer in the open scene, as one undoable step. Baked files stay.",
			Clicked = RemoveAll,
		} );
		footer.AddStretchCell();
		_apply = footer.Add( new Button.Primary( "Flatpack Selected", "view_in_ar" ) { Clicked = OnApplyClicked } );
	}

	public override void OnDestroyed()
	{
		_cancel?.Cancel();
		if ( _current == this )
			_current = null;

		base.OnDestroyed();
	}

	/// <summary>
	/// Gather every model in the open scene and decide what to recommend for each.
	/// </summary>
	public void Scan()
	{
		// Keep the user's ticks across rescans (the scene may have changed under us, e.g. after Ctrl+Z).
		var scene = SceneEditorSession.Active?.Scene;
		var previous = scene == _scannedScene
			? _entries.Where( e => e.Advice != Advice.Done && e.Advice != Advice.Skip ).ToDictionary( e => e.Model, e => e.Selected )
			: new Dictionary<Model, bool>();
		_scannedScene = scene;
		_entries.Clear();
		if ( scene is null )
		{
			Rebuild();
			return;
		}

		foreach ( var renderer in scene.GetAllComponents<ModelRenderer>() )
		{
			var model = renderer.Model;
			if ( model is null || model.IsError )
				continue;

			var entry = _entries.FirstOrDefault( e => e.Model == model );
			if ( entry is null )
			{
				entry = new Entry { Model = model, Triangles = Lod0Triangles( model ) };
				_entries.Add( entry );
			}

			entry.Renderers.Add( renderer );
			if ( renderer.Components.Get<FlatpackRenderer>().IsValid() )
				entry.Flatpacked++;
		}

		foreach ( var entry in _entries )
		{
			entry.Impostor = FlatpackRenderer.FindImpostorFor( entry.Model );
			Recommend( entry );
			entry.Selected = entry.Advice != Advice.Done && entry.Advice != Advice.Skip
				&& ( previous.TryGetValue( entry.Model, out var was ) ? was : entry.Advice == Advice.Recommended );
		}

		// Biggest wins first.
		_entries.Sort( ( a, b ) => b.TotalTriangles.CompareTo( a.TotalTriangles ) );
		Rebuild();
	}

	/// <summary>
	/// The recommendation rules. Kept in one place so the reasons in the list always match the decision.
	/// </summary>
	/// <summary>
	/// Typical time to bake one model (including the quality check after it) and the video memory its atlases use, by
	/// quality level. Measured on an RTX 3090 in iterations 4 to 6; Auto bakes Low first and climbs, so it's a range.
	/// </summary>
	static (float Seconds, float MaxSeconds, float Megabytes) BakeCost( FlatpackBakeSettings settings )
	{
		// Supersampling renders each view larger; 2 is the default the times below were measured at.
		var ss = Math.Clamp( settings.Supersample, 1, 4 );
		var time = ss switch { 1 => 0.75f, 2 => 1.0f, 3 => 1.3f, _ => 1.7f };
		if ( settings.BakeSelfShadow )
			time *= 3.0f;

		return settings.Quality switch
		{
			FlatpackQuality.Low => (3 * time, 3 * time, 4),
			FlatpackQuality.Medium => (6 * time, 6 * time, 16),
			FlatpackQuality.High => (16 * time, 16 * time, 64),
			FlatpackQuality.Ultra => (12 * time, 12 * time, 36),
			FlatpackQuality.Auto => (4 * time, 40 * time, 16),
			_ => CustomCost( settings, time ),
		};
	}

	static (float, float, float) CustomCost( FlatpackBakeSettings settings, float time )
	{
		var (grid, atlas, _) = settings.Resolve();
		var size = (float)(int)atlas;
		var megabytes = size * size * 3 * 4 / 3 / (1024 * 1024);
		var seconds = (2.0f + 4.0f * (size / 2048) * (size / 2048) * MathF.Max( 1, grid / 12.0f )) * time;
		return (seconds, seconds, megabytes);
	}

	static string Duration( float seconds ) => seconds < 90 ? $"{seconds:0} s" : $"{seconds / 60:0.#} min";

	/// <summary>
	/// Triangles in the model's most detailed LOD, every mesh and draw call. <c>Model.GetIndexCountForLod</c> counts only
	/// the first draw call, which undercounts models with several materials (a tree's bark and leaves: 293,609 of 534,024).
	/// </summary>
	public static int Lod0Triangles( Model model )
	{
		var meshes = model.MeshInfo?.Meshes;
		if ( meshes is null || meshes.Length == 0 )
			return model.GetIndexCountForLod( 0 ) / 3;

		var total = meshes.Where( m => ((long)m.LodMask & 1) != 0 ).Sum( m => (long)m.Triangles );
		return (int)Math.Min( total > 0 ? total : model.GetIndexCountForLod( 0 ) / 3, int.MaxValue );
	}

	static void Recommend( Entry e )
	{
		if ( e.Flatpacked == e.Instances )
		{
			e.Advice = Advice.Done;
			e.Reason = "Already Flatpacked.";
			return;
		}

		if ( e.Renderers.Any( r => r is SkinnedModelRenderer ) )
		{
			e.Advice = Advice.Skip;
			e.Reason = "Animated: a card can't show animation.";
			return;
		}

		if ( e.Triangles <= 12 )
		{
			e.Advice = Advice.Skip;
			e.Reason = "Already as simple as a card.";
			return;
		}

		if ( e.Model.RenderBounds.Size.Length < 8.0f )
		{
			e.Advice = Advice.Skip;
			e.Reason = "Tiny: it's only a few pixels before a card would take over.";
			return;
		}

		if ( e.Triangles < MinTriangles && e.TotalTriangles < MinTotalTriangles )
		{
			e.Advice = Advice.LowGain;
			e.Reason = $"Cheap already ({e.Triangles:n0} triangles): a card saves little here.";
			return;
		}

		e.Advice = Advice.Recommended;
		e.Reason = e.Triangles >= MinTriangles
			? $"{e.Triangles:n0} triangles each: big saving at a distance."
			: $"{e.TotalTriangles:n0} triangles across {e.Instances} copies.";

		if ( e.Impostor is not null && e.Impostor.ThinDetail > ThinDetailWarning )
			e.Reason += " Has thin parts (twigs, wire): check it holds up at your swap distance.";
	}

	void SelectWhere( Func<Entry, bool> predicate )
	{
		foreach ( var e in _entries )
			e.Selected = predicate( e );

		Rebuild();
	}

	void Rebuild()
	{
		if ( !IsValid )
			return;

		_list.Canvas.Layout.Clear( true );

		if ( _entries.Count == 0 )
		{
			_list.Canvas.Layout.Add( new Label( SceneEditorSession.Active is null ? "Open a scene first." : "No models in this scene." ) );
		}

		var header = _list.Canvas.Layout.AddRow();
		header.Spacing = 8;
		foreach ( var (text, width) in new[] { ("", 24), ("Model", 230), ("Copies", 60), ("Triangles", 90), ("Status", 90), ("Recommendation", 330) } )
		{
			var l = header.Add( new Label( text ) { FixedWidth = width } );
			l.SetStyles( "font-weight: bold;" );
		}

		foreach ( var entry in _entries )
		{
			var row = _list.Canvas.Layout.AddRow();
			row.Spacing = 8;

			var check = row.Add( new Checkbox( "" ) { FixedWidth = 24, Value = entry.Selected } );
			check.Enabled = entry.Advice != Advice.Done;
			check.StateChanged = _ => { entry.Selected = check.Value; UpdateSummary(); };

			row.Add( new Label( entry.Model.ResourceName ) { FixedWidth = 230, ToolTip = entry.Model.ResourcePath } );
			row.Add( new Label( $"{entry.Instances}" ) { FixedWidth = 60 } );
			row.Add( new Label( $"{entry.Triangles:n0}" ) { FixedWidth = 90 } );

			var status = entry.Flatpacked == entry.Instances ? "Done"
				: entry.Flatpacked > 0 ? $"{entry.Flatpacked}/{entry.Instances}"
				: entry.Impostor is not null ? "Baked" : "Not baked";
			row.Add( new Label( status ) { FixedWidth = 90 } );

			var advice = row.Add( new Label( entry.Reason ) { FixedWidth = 330, WordWrap = true } );
			advice.Color = entry.Advice switch
			{
				Advice.Recommended => Theme.Green,
				Advice.LowGain => Theme.Yellow,
				Advice.Skip => Theme.TextLight.WithAlpha( 0.6f ),
				_ => Theme.TextLight,
			};
		}

		_list.Canvas.Layout.AddStretchCell();
		UpdateSummary();
	}

	void UpdateSummary()
	{
		var picked = _entries.Where( e => e.Selected && e.Advice != Advice.Done ).ToArray();
		var instances = picked.Sum( e => e.Instances - e.Flatpacked );
		var before = picked.Sum( e => (long)e.Triangles * (e.Instances - e.Flatpacked) );
		var toBake = picked.Count( e => e.Impostor is null );

		if ( picked.Length == 0 )
		{
			_summary.Text = "Nothing selected.";
			_apply.Enabled = false;
			return;
		}

		var settings = EditorCookie.Get( "Flatpack.BakeSettings", new FlatpackBakeSettings() ) ?? new FlatpackBakeSettings();
		var cost = BakeCost( settings );
		var bakeText = toBake == 0
			? "Everything is baked already."
			: cost.MaxSeconds > cost.Seconds
				? $"{toBake} to bake at {settings.Quality}: about {Duration( toBake * cost.Seconds )} (up to {Duration( toBake * cost.MaxSeconds )}), adding about {toBake * cost.Megabytes:n0} MB of video memory."
				: $"{toBake} to bake at {settings.Quality}: about {Duration( toBake * cost.Seconds )}, adding about {toBake * cost.Megabytes:n0} MB of video memory.";

		_summary.Text = $"{picked.Length} model(s), {instances:n0} object(s). Far away they draw {instances * 2:n0} triangles instead of {before:n0}. {bakeText} " +
			"Gains are biggest where you can see far (forests, open maps); in closed rooms the cost is usually lights and draw calls.";

		_apply.Enabled = picked.Length > 0 && _cancel is null;
	}

	async void OnApplyClicked()
	{
		if ( _cancel is not null )
		{
			_cancel.Cancel();
			return;
		}

		// The scene may have changed since the list was built (edits, Ctrl+Z): start from what's there now.
		Scan();
		var picked = _entries.Where( e => e.Selected && e.Advice != Advice.Done ).ToList();
		if ( picked.Count == 0 )
			return;

		_cancel = new CancellationTokenSource();
		_apply.Text = "Cancel";
		_apply.Enabled = true;

		try
		{
			// Bake what's missing first; scene changes come after, in one undo step.
			var toBake = picked.Where( e => e.Impostor is null ).ToList();
			var settings = EditorCookie.Get( "Flatpack.BakeSettings", new FlatpackBakeSettings() ) ?? new FlatpackBakeSettings();

			for ( var i = 0; i < toBake.Count; i++ )
			{
				var entry = toBake[i];
				var index = i;
				var result = await FlatpackBaker.BakeAsync( entry.Model, settings,
					( f, text ) => { if ( IsValid ) _status.Text = $"Baking {entry.Model.ResourceName} ({index + 1} of {toBake.Count}): {text}"; },
					_cancel.Token );
				entry.Impostor = result.Impostor;
			}

			var added = Apply( picked );
			_status.Text = $"Added a Flatpack Renderer to {added:n0} object(s). Ctrl+Z undoes it, or use Remove Flatpack from Scene.";
		}
		catch ( OperationCanceledException )
		{
			_status.Text = "Cancelled. Nothing in the scene changed; finished bakes are kept.";
		}
		catch ( Exception ex )
		{
			_status.Text = $"Stopped: {ex.Message}";
			Log.Warning( $"Flatpack: scene optimise stopped: {ex}" );
		}
		finally
		{
			_cancel = null;
			if ( IsValid )
			{
				_apply.Text = "Flatpack Selected";
				Scan();
			}
		}
	}

	/// <summary>
	/// Add a Flatpack Renderer to every instance of the picked models that doesn't have one, as a single undo step.
	/// </summary>
	static int Apply( IEnumerable<Entry> picked )
	{
		var session = SceneEditorSession.Active;
		if ( session is null )
			return 0;

		var targets = picked
			.Where( e => e.Impostor is not null )
			.SelectMany( e => e.Renderers.Select( r => (Renderer: r, e.Impostor) ) )
			.Where( x => x.Renderer.IsValid() && !x.Renderer.Components.Get<FlatpackRenderer>().IsValid() )
			.ToArray();

		if ( targets.Length == 0 )
			return 0;

		using var scene = session.Scene.Push();
		using ( session.UndoScope( $"Flatpack {targets.Length} Objects" ).WithComponentCreations().Push() )
		{
			foreach ( var (renderer, impostor) in targets )
			{
				var flatpack = renderer.GameObject.Components.Create<FlatpackRenderer>();
				flatpack.Source = renderer;
				flatpack.Impostor = impostor;
			}
		}

		session.HasUnsavedChanges = true;
		return targets.Length;
	}

	void RemoveAll()
	{
		var session = SceneEditorSession.Active;
		if ( session is null )
			return;

		var all = session.Scene.GetAllComponents<FlatpackRenderer>().ToArray();
		if ( all.Length == 0 )
		{
			_status.Text = "No Flatpack Renderers in this scene.";
			return;
		}

		using ( session.Scene.Push() )
		using ( session.UndoScope( $"Remove Flatpack from {all.Length} Objects" ).WithComponentDestructions( all ).Push() )
		{
			foreach ( var c in all )
				c.Destroy();
		}

		session.HasUnsavedChanges = true;
		_status.Text = $"Removed {all.Length:n0} Flatpack Renderer(s). Ctrl+Z puts them back.";
		Scan();
	}
}
anakin.flatpack / FlatpackImpostor.cs
Game library
using System.Linq;
using System.Text.Json.Serialization;
using Sandbox;

namespace Flatpack;

/// <summary>
/// A baked impostor: the atlas material plus everything the runtime needs to place the card.
/// Created by the Flatpack baker (Editor menu, Flatpack, Impostor Baker), one per source model.
/// </summary>
/// <remarks>
/// <para>Use it with <see cref="FlatpackRenderer"/>, which swaps between the real model and the card by distance.</para>
/// <para>
/// To use the card as the last LOD of a <c>.vmdl</c> instead, add a LOD to the model in ModelDoc whose only mesh is a
/// single quad (any size, centred on the model) with this impostor's material, UVs (0,0) top-left to (1,1)
/// bottom-right. The shader ignores the quad's positions and rebuilds the card from its UVs, the bake bounds and the
/// instance transform, so the quad's size and orientation don't matter. Flatpack doesn't write <c>.vmdl</c> files for you.
/// </para>
/// </remarks>
[AssetType( Name = "Flatpack Impostor", Extension = "fpack", Category = "Rendering" )]
public sealed class FlatpackImpostor : GameResource
{
	/// <summary>
	/// The model this impostor was baked from.
	/// </summary>
	[Property] public Model SourceModel { get; set; }

	/// <summary>
	/// Material using <c>shaders/flatpack_impostor.shader</c> with the baked atlases.
	/// </summary>
	[Property] public Material Material { get; set; }

	/// <summary>
	/// How the frames are spread over view directions.
	/// </summary>
	[Property, ReadOnly] public FlatpackLayout Layout { get; set; } = FlatpackLayout.Hemisphere;

	/// <summary>
	/// Frames per atlas side. The atlas holds GridSize x GridSize frames.
	/// </summary>
	[Property, ReadOnly] public int GridSize { get; set; } = 8;

	/// <summary>
	/// Atlas width and height in pixels.
	/// </summary>
	[Property, ReadOnly] public int AtlasSize { get; set; } = 2048;

	/// <summary>
	/// Centre of the baked bounding sphere, in the model's object space (the pivot offset).
	/// </summary>
	[Property, ReadOnly] public Vector3 Center { get; set; }

	/// <summary>
	/// Radius of the baked bounding sphere, in object space units.
	/// </summary>
	[Property, ReadOnly] public float Radius { get; set; } = 1.0f;

	/// <summary>
	/// The source model's render bounds at bake time.
	/// </summary>
	[Property, ReadOnly] public BBox Bounds { get; set; }

	/// <summary>
	/// How much alpha the baker had to add back at mip level 2 to keep thin parts covered (1 = none). High values mean
	/// twigs, spindles or wire mesh that the card keeps only from further away; the scene optimiser warns about them.
	/// </summary>
	[Property, ReadOnly] public float ThinDetail { get; set; } = 1.0f;

	/// <summary>
	/// Quality level the bake was made at (Low, Medium, High, Ultra or Custom).
	/// </summary>
	[Property, ReadOnly] public string QualityLevel { get; set; }

	/// <summary>
	/// Nearest distance, in bounding radii, from which the baker measured the card matching its model: at most 15% of
	/// pixels clearly different from there out to 16 radii. 0 when not measured, -1 when it never got there.
	/// <see cref="FlatpackRenderer"/>'s automatic swap distance never goes closer than this.
	/// </summary>
	[Property, ReadOnly] public float HoldsUpFrom { get; set; }

	/// <summary>
	/// Share of the covered texels that are only partly covered (0 to 1). Above <c>0.4</c> the bake is treated as
	/// see-through detail (wire mesh, twigs, nets) and drawn with a lowered alpha cut the baker fits to the model.
	/// </summary>
	[Property, ReadOnly] public float SeeThrough { get; set; }

	Model _cardModel;
	Material _cardMaterial;

	/// <summary>
	/// The shared card model for this impostor: one quad with the impostor material. Built on first use and reused
	/// by every <see cref="FlatpackRenderer"/> that points here, so identical cards can be instanced by the engine.
	/// </summary>
	[JsonIgnore, Hide]
	public Model CardModel
	{
		get
		{
			if ( _cardModel is not null && _cardMaterial == Material )
				return _cardModel;

			_cardMaterial = Material;
			_cardModel = FlatpackCard.Build( this );
			return _cardModel;
		}
	}

	Texture[] _atlases;
	Material _atlasMaterial;

	/// <summary>
	/// Tell texture streaming this impostor's atlases are on screen and needed at full size.
	/// </summary>
	/// <remarks>
	/// The streamer can't judge the card's real texel density: the card shows one frame (1/GridSize of the atlas)
	/// across the whole quad, and the shader picks UVs itself. Left alone it kept a 2048 atlas at 512 or 256 and the
	/// impostor looked blurred. <see cref="FlatpackSystem"/> calls this every frame for impostors with a visible card.
	/// </remarks>
	public void MarkAtlasesUsed()
	{
		if ( Material is null )
			return;

		if ( _atlases is null || _atlasMaterial != Material )
		{
			_atlasMaterial = Material;
			// Bakes from before iteration 8 use the combined normal and depth atlas; later ones the separate pair.
			_atlases = new[] { "g_tFlatpackAlbedo", "g_tFlatpackNormalDepth", "g_tFlatpackNormal", "g_tFlatpackDepth", "g_tFlatpackDepth8", "g_tFlatpackDepthMin", "g_tFlatpackFrames", "g_tFlatpackRoughnessAo" }
				.Select( Material.GetTexture )
				.Where( t => t is not null )
				.ToArray();
		}

		foreach ( var texture in _atlases )
			texture.MarkUsed( AtlasSize );
	}

	protected override void PostReload()
	{
		base.PostReload();

		// Rebuild the card next time it's asked for, in case a re-bake changed the bounds.
		_cardModel = null;
		_atlases = null;
	}
}
Debug: View Raw JSON Response
{
    "TotalCount": 22,
    "Files": [
        {
            "Ident": "anakin.flatpack",
            "Path": "FlatpackSystem.cs",
            "FileName": "FlatpackSystem.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\n\r\nnamespace Flatpack;\r\n\r\n/// <summary>\r\n/// How hard Flatpack works, for a game's graphics settings: high quality presets turn it off, low ones lean on it.\r\n/// Set <see cref=\"FlatpackSystem.Level\"/>; every Flatpack Renderer follows it within a few frames, nothing re-bakes.\r\n/// </summary>\r\npublic enum FlatpackLevel\r\n{\r\n\t/// <summary>Real models everywhere; cards never show (a game's High or Ultra preset).</summary>\r\n\tOff,\r\n\r\n\t/// <summary>Swap where the bake was measured to look like the model (the default; a game's Medium).</summary>\r\n\tBalanced,\r\n\r\n\t/// <summary>Swap at 0.6 times the distance, from 3 bounding radii (a game's Low).</summary>\r\n\tPerformance,\r\n\r\n\t/// <summary>Swap at 0.35 times the distance, from 1.5 bounding radii (a game's lowest preset or weak hardware).</summary>\r\n\tMaximum,\r\n}\r\n\r\n/// <summary>\r\n/// Runs the model/card distance check for every <see cref=\"FlatpackRenderer\"/> in the scene.\r\n/// </summary>\r\n/// <remarks>\r\n/// One loop per frame instead of one Update per renderer. The loop is time-sliced: each frame checks at most\r\n/// <see cref=\"RenderersPerFrame\"/> renderers, round-robin, so a forest of 5000 trees spreads its checks over a few\r\n/// frames. Renderers in the middle of a cross-fade are ticked every frame until they finish.\r\n/// </remarks>\r\npublic sealed class FlatpackSystem : GameObjectSystem<FlatpackSystem>\r\n{\r\n\t/// <summary>\r\n\t/// Global level for every Flatpack Renderer, meant to be driven by a game's graphics settings, for example\r\n\t/// <c>FlatpackSystem.Level = FlatpackLevel.Performance;</c> on the Low preset. Applies to automatic and manual swap\r\n\t/// distances alike; Force Model and Force Card modes ignore it.\r\n\t/// </summary>\r\n\tpublic static FlatpackLevel Level { get; set; } = FlatpackLevel.Balanced;\r\n\r\n\t/// <summary>\r\n\t/// What <see cref=\"Level\"/> does to a swap distance: a multiplier, and the closest swap allowed in bounding radii\r\n\t/// (0 = the renderer's own minimum). <see cref=\"FlatpackLevel.Off\"/> pushes the swap out to infinity.\r\n\t/// </summary>\r\n\tpublic static (float Scale, float MinimumRadii) LevelScaling => Level switch\r\n\t{\r\n\t\tFlatpackLevel.Off => (float.PositiveInfinity, 0.0f),\r\n\t\tFlatpackLevel.Performance => (0.6f, 3.0f),\r\n\t\tFlatpackLevel.Maximum => (0.35f, 1.5f),\r\n\t\t_ => (1.0f, 0.0f),\r\n\t};\r\n\r\n\t/// <summary>\r\n\t/// Distance checks per frame. Lower spreads the work thinner but makes swaps lag behind a fast camera.\r\n\t/// </summary>\r\n\tpublic int RenderersPerFrame { get; set; } = 2048;\r\n\r\n\t/// <summary>\r\n\t/// Number of registered renderers.\r\n\t/// </summary>\r\n\tpublic int Count => _renderers.Count;\r\n\r\n\treadonly List<FlatpackRenderer> _renderers = new();\r\n\treadonly List<FlatpackRenderer> _fading = new();\r\n\treadonly Dictionary<FlatpackImpostor, int> _visibleCards = new();\r\n\tint _cursor;\r\n\r\n\tpublic FlatpackSystem( Scene scene ) : base( scene )\r\n\t{\r\n\t\t// After the scene update, so this frame's camera and object movement are already in.\r\n\t\tListen( Stage.FinishUpdate, 100, Tick, \"Flatpack.DistancePass\" );\r\n\t}\r\n\r\n\tinternal void Register( FlatpackRenderer renderer )\r\n\t{\r\n\t\tif ( renderer.SystemIndex >= 0 )\r\n\t\t\treturn;\r\n\r\n\t\trenderer.SystemIndex = _renderers.Count;\r\n\t\t_renderers.Add( renderer );\r\n\t}\r\n\r\n\tinternal void Unregister( FlatpackRenderer renderer )\r\n\t{\r\n\t\tvar index = renderer.SystemIndex;\r\n\t\tif ( index < 0 || index >= _renderers.Count || _renderers[index] != renderer )\r\n\t\t\treturn;\r\n\r\n\t\t// Swap-remove keeps this O(1); the moved renderer takes over the freed slot.\r\n\t\tvar last = _renderers[^1];\r\n\t\t_renderers[index] = last;\r\n\t\tlast.SystemIndex = index;\r\n\t\t_renderers.RemoveAt( _renderers.Count - 1 );\r\n\r\n\t\trenderer.SystemIndex = -1;\r\n\t\t_fading.Remove( renderer );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Reference count of visible cards per impostor, kept by <see cref=\"FlatpackRenderer\"/>, so the tick can keep\r\n\t/// those atlases streamed in without walking every renderer.\r\n\t/// </summary>\r\n\tinternal void CardVisibilityChanged( FlatpackImpostor impostor, bool visible )\r\n\t{\r\n\t\tif ( impostor is null )\r\n\t\t\treturn;\r\n\r\n\t\t_visibleCards.TryGetValue( impostor, out var count );\r\n\t\tcount += visible ? 1 : -1;\r\n\r\n\t\tif ( count > 0 )\r\n\t\t\t_visibleCards[impostor] = count;\r\n\t\telse\r\n\t\t\t_visibleCards.Remove( impostor );\r\n\t}\r\n\r\n\tvoid Tick()\r\n\t{\r\n\t\tforeach ( var impostor in _visibleCards.Keys )\r\n\t\t\timpostor.MarkAtlasesUsed();\r\n\r\n\t\tif ( _renderers.Count == 0 )\r\n\t\t\treturn;\r\n\r\n\t\tvar camera = Scene.Camera;\r\n\t\tif ( !camera.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tvar cameraPosition = camera.WorldPosition;\r\n\t\tvar count = Math.Min( RenderersPerFrame, _renderers.Count );\r\n\r\n\t\tfor ( var i = 0; i < count; i++ )\r\n\t\t{\r\n\t\t\tif ( _cursor >= _renderers.Count )\r\n\t\t\t\t_cursor = 0;\r\n\r\n\t\t\tvar renderer = _renderers[_cursor++];\r\n\t\t\tvar distanceSquared = cameraPosition.DistanceSquared( renderer.WorldPosition );\r\n\r\n\t\t\tif ( renderer.Evaluate( distanceSquared ) && !_fading.Contains( renderer ) )\r\n\t\t\t\t_fading.Add( renderer );\r\n\t\t}\r\n\r\n\t\tif ( _fading.Count == 0 )\r\n\t\t\treturn;\r\n\r\n\t\tvar delta = Time.Delta;\r\n\t\tfor ( var i = _fading.Count - 1; i >= 0; i-- )\r\n\t\t{\r\n\t\t\tif ( !_fading[i].TickFade( delta ) )\r\n\t\t\t\t_fading.RemoveAt( i );\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Editor/FlatpackPfm.cs",
            "FileName": "FlatpackPfm.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System.IO;\nusing System.Text;\n\nnamespace Flatpack.EditorTools;\n\n/// <summary>\n/// Writes greyscale PFM (portable float map) files. The baker stores the 16-bit depth atlas this way: s&amp;box's texture\n/// compiler reads PNGs at 8 bits whatever their bit depth, but keeps a float source's precision when it builds an R16\n/// texture (measured in iteration 8: every value of a 16-bit ramp came back exact).\n/// </summary>\nstatic class FlatpackPfm\n{\n\t/// <summary>\n\t/// Write <paramref name=\"pixels\"/> (row by row, top row first) as a <paramref name=\"width\"/> x\n\t/// <paramref name=\"height\"/> single-channel float image.\n\t/// </summary>\n\t/// <remarks>\n\t/// The PFM format stores rows bottom first, but the s&amp;box texture compiler reads the first row as the top, so rows\n\t/// go out in image order.\n\t/// </remarks>\n\tpublic static void WriteGray( string path, float[] pixels, int width, int height )\n\t{\n\t\tusing var file = File.Create( path );\n\t\tusing var writer = new BinaryWriter( file );\n\n\t\t// \"Pf\" is one channel; a negative scale means little-endian floats.\n\t\twriter.Write( Encoding.ASCII.GetBytes( $\"Pf\\n{width} {height}\\n-1.0\\n\" ) );\n\t\tfor ( var i = 0; i < width * height; i++ )\n\t\t\twriter.Write( pixels[i] );\n\t}\n}\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Editor/FlatpackBaker.cs",
            "FileName": "FlatpackBaker.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.Diagnostics;\nusing System.Globalization;\nusing System.IO;\nusing System.Linq;\nusing System.Text;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing Editor;\nusing Sandbox;\n\nnamespace Flatpack.EditorTools;\n\n/// <summary>\n/// Bakes a model into a Flatpack impostor: albedo, normal, depth and roughness + AO atlases, a frame lookup texture, a\n/// material using <c>flatpack_impostor.shader</c>, and a <see cref=\"FlatpackImpostor\"/> resource that ties them together.\n/// </summary>\n/// <remarks>\n/// <para>\n/// The model is rendered alone in a throwaway scene with an orthographic camera, once per frame of the\n/// N x N grid. Each camera is fitted to that view's outline and depth range (stored in the frame lookup texture), so the\n/// object fills its frame. Each pass uses one of the engine's camera debug views, which write material data straight to\n/// the target with no lighting, exposure or tonemapping:\n/// </para>\n/// <list type=\"bullet\">\n/// <item>Albedo: the colour texture as sRGB bytes, alpha = coverage.</item>\n/// <item>NormalMap: the normal-mapped world normal packed as n * 0.5 + 0.5. The model sits at the origin with no\n/// rotation, so world space is object space.</item>\n/// <item>Roughness, AmbientOcclusion: linear values as bytes.</item>\n/// <item>Depth: there is no depth view, so <c>flatpack_bake_depth.shader</c> goes on as a material override and\n/// writes linear depth as albedo (16 bits split over red and green), read back through the Albedo view.</item>\n/// </list>\n/// </remarks>\npublic static class FlatpackBaker\n{\n\t/// <summary>\n\t/// The runtime impostor shader the generated material uses.\n\t/// </summary>\n\tpublic const string ImpostorShader = \"shaders/flatpack_impostor.shader\";\n\n\tconst string DepthShader = \"shaders/flatpack_bake_depth.shader\";\n\n\t/// <summary>Opacity at which a supersample counts as covered: the usual alpha-test cut of s&amp;box materials.</summary>\n\tconst byte AlphaTestCut = 128;\n\n\t/// <summary>\n\t/// Bakes whose covered texels are more than this share partly covered are see-through detail. Measured on the test\n\t/// set: fence 0.75, weed tree 0.50, everything solid or leafy 0.06 to 0.24.\n\t/// </summary>\n\tpublic const float SeeThroughShare = 0.4f;\n\n\t/// <summary>\n\t/// Framed bakes whose covered texels are more than this share partly covered are fine wire mesh and get baked again\n\t/// unframed. Measured framed in iteration 8: fence 0.59, weed tree 0.42, solid and leafy models 0.10 or less.\n\t/// </summary>\n\tconst float FineMeshShare = 0.5f;\n\n\t/// <summary>\n\t/// What a bake wrote.\n\t/// </summary>\n\tpublic sealed class Result\n\t{\n\t\t/// <summary>The <c>.fpack</c> resource, loaded.</summary>\n\t\tpublic FlatpackImpostor Impostor { get; init; }\n\n\t\t/// <summary>Asset-relative path of the <c>.fpack</c>.</summary>\n\t\tpublic string ImpostorPath { get; init; }\n\n\t\t/// <summary>Asset-relative path of the generated <c>.vmat</c>.</summary>\n\t\tpublic string MaterialPath { get; init; }\n\n\t\t/// <summary>Absolute paths of every file written.</summary>\n\t\tpublic IReadOnlyList<string> Files { get; init; }\n\n\t\t/// <summary>Wall-clock bake time.</summary>\n\t\tpublic TimeSpan Duration { get; init; }\n\n\t\t/// <summary>The quality level the bake ended up at (Auto picks one).</summary>\n\t\tpublic FlatpackQuality Level { get; init; }\n\n\t\t/// <summary>How closely the card matches the model by distance, when the bake was measured.</summary>\n\t\tpublic FlatpackQualityCheck.Report Measured { get; init; }\n\t}\n\n\t/// <summary>\n\t/// Bake one model at the settings' quality level, or for <see cref=\"FlatpackQuality.Auto\"/> at the cheapest level that\n\t/// matches the model from <see cref=\"FlatpackBakeSettings.AutoTargetRadii\"/> outward. Measures the result when\n\t/// <see cref=\"FlatpackBakeSettings.MeasureQuality\"/> is on (always for Auto) and stores where it holds up.\n\t/// </summary>\n\tpublic static async Task<Result> BakeAsync( Model model, FlatpackBakeSettings settings, Action<float, string> progress = null, CancellationToken cancel = default )\n\t{\n\t\tif ( settings.Quality != FlatpackQuality.Auto )\n\t\t{\n\t\t\tvar result = await BakeLevelAsync( model, settings, progress, cancel );\n\t\t\treturn settings.MeasureQuality ? await MeasureAsync( result, progress, cancel ) : result;\n\t\t}\n\n\t\t// Auto: climb the levels until one holds up at the target distance. Each bake overwrites the last.\n\t\tvar timer = Stopwatch.StartNew();\n\t\tvar levels = new[] { FlatpackQuality.Low, FlatpackQuality.Medium, FlatpackQuality.High, FlatpackQuality.Ultra };\n\t\tvar best = FlatpackQuality.Ultra;\n\t\tvar bestScore = float.MaxValue;\n\t\tResult last = null;\n\n\t\tfor ( var i = 0; i < levels.Length; i++ )\n\t\t{\n\t\t\tvar level = levels[i];\n\t\t\tvar step = i;\n\t\t\tvar baked = await BakeLevelAsync( model, settings.WithQuality( level ),\n\t\t\t\t( f, text ) => progress?.Invoke( (step + f * 0.8f) / levels.Length, $\"{level}: {text}\" ), cancel );\n\t\t\tlast = await MeasureAsync( baked, ( f, text ) => progress?.Invoke( (step + 0.8f + f * 0.2f) / levels.Length, $\"{level}: {text}\" ), cancel );\n\n\t\t\t// See-through detail goes straight to Ultra. At Medium a wire grid has about one texel per wire, so its frames\n\t\t\t// line up badly and the panel doubles when seen at an angle, even where the bake check passes it (iteration 7).\n\t\t\tif ( last.Impostor.SeeThrough > SeeThroughShare && level != FlatpackQuality.Ultra )\n\t\t\t{\n\t\t\t\ti = levels.Length - 2;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Holds up from the target (judged out to 16 radii; past that every level matches as well as it ever will).\n\t\t\tif ( last.Measured.HoldsUpFrom <= MathF.Min( settings.AutoTargetRadii, 16.0f ) )\n\t\t\t\treturn Finish( last, timer );\n\n\t\t\tvar score = last.Measured.HoldsUpFrom;\n\n\t\t\tif ( score < bestScore )\n\t\t\t{\n\t\t\t\tbestScore = score;\n\t\t\t\tbest = level;\n\t\t\t}\n\t\t}\n\n\t\t// Nothing held up (fine wire mesh, for example). Keep whichever level held up from closest.\n\t\tif ( best != FlatpackQuality.Ultra )\n\t\t{\n\t\t\tvar baked = await BakeLevelAsync( model, settings.WithQuality( best ), progress, cancel );\n\t\t\tlast = await MeasureAsync( baked, progress, cancel );\n\t\t}\n\n\t\treturn Finish( last, timer );\n\t}\n\n\tstatic Result Finish( Result r, Stopwatch timer ) => new()\n\t{\n\t\tImpostor = r.Impostor, ImpostorPath = r.ImpostorPath, MaterialPath = r.MaterialPath, Files = r.Files,\n\t\tLevel = r.Level, Measured = r.Measured, Duration = timer.Elapsed,\n\t};\n\n\t/// <summary>\n\t/// Measure a finished bake and store where it holds up on its <c>.fpack</c>.\n\t/// </summary>\n\tstatic async Task<Result> MeasureAsync( Result baked, Action<float, string> progress, CancellationToken cancel )\n\t{\n\t\tif ( baked.Impostor.SeeThrough > SeeThroughShare )\n\t\t{\n\t\t\tprogress?.Invoke( 0.0f, \"Fitting see-through coverage to the model\" );\n\t\t\tvar (soft, growth) = await FlatpackQualityCheck.FitSoftCoverageAsync( baked.Impostor, cancel );\n\t\t\tWriteMaterialValue( baked.MaterialPath, \"g_flFlatpackSoftCoverage\", soft );\n\t\t\tWriteMaterialValue( baked.MaterialPath, \"g_flFlatpackSoftGrowth\", growth );\n\t\t}\n\n\t\tprogress?.Invoke( 0.0f, \"Measuring against the model\" );\n\t\tvar report = await FlatpackQualityCheck.MeasureAsync( baked.Impostor, cancel: cancel );\n\n\t\tvar impostor = baked.Impostor;\n\t\timpostor.HoldsUpFrom = float.IsPositiveInfinity( report.HoldsUpFrom ) ? -1.0f : report.HoldsUpFrom;\n\t\tAssetSystem.FindByPath( baked.ImpostorPath )?.SaveToDisk( impostor );\n\t\tprogress?.Invoke( 1.0f, report.Summary );\n\n\t\treturn new Result\n\t\t{\n\t\t\tImpostor = impostor, ImpostorPath = baked.ImpostorPath, MaterialPath = baked.MaterialPath, Files = baked.Files,\n\t\t\tDuration = baked.Duration, Level = baked.Level, Measured = report,\n\t\t};\n\t}\n\n\t/// <summary>\n\t/// Share of covered texels (alpha above 2 of 255) that are only partly covered (below 250).\n\t/// </summary>\n\tstatic float PartialCoverageShare( Color32[] albedo )\n\t{\n\t\tlong covered = 0, partial = 0;\n\t\tforeach ( var c in albedo )\n\t\t{\n\t\t\tif ( c.a <= 2 ) continue;\n\t\t\tcovered++;\n\t\t\tif ( c.a < 250 ) partial++;\n\t\t}\n\t\treturn covered > 0 ? partial / (float)covered : 0.0f;\n\t}\n\n\t/// <summary>\n\t/// Set one value in a generated <c>.vmat</c> (adding the line if it's missing) and recompile it.\n\t/// </summary>\n\tstatic void WriteMaterialValue( string materialPath, string key, float value )\n\t{\n\t\tvar asset = AssetSystem.FindByPath( materialPath );\n\t\tif ( asset is null || !File.Exists( asset.AbsolutePath ) )\n\t\t\treturn;\n\n\t\tvar line = $\"\\t{key} \\\"{value.ToString( \"0.000000\", CultureInfo.InvariantCulture )}\\\"\";\n\t\tvar lines = File.ReadAllLines( asset.AbsolutePath ).ToList();\n\t\tvar index = lines.FindIndex( l => l.TrimStart().StartsWith( key + \" \" ) );\n\t\tif ( index >= 0 )\n\t\t\tlines[index] = line;\n\t\telse\n\t\t\tlines.Insert( Math.Max( 0, lines.FindLastIndex( l => l.Trim() == \"}\" ) ), line );\n\n\t\tFile.WriteAllLines( asset.AbsolutePath, lines );\n\t\tasset.Compile( true );\n\t}\n\n\t/// <summary>\n\t/// Bake one model at one quality level. Runs on the main thread but yields between frames, so the editor keeps\n\t/// drawing; cancel with <paramref name=\"cancel\"/>. <paramref name=\"progress\"/> gets a 0..1 fraction and a status line.\n\t/// </summary>\n\tstatic async Task<Result> BakeLevelAsync( Model model, FlatpackBakeSettings settings, Action<float, string> progress, CancellationToken cancel )\n\t{\n\t\tif ( model is null || model.IsError )\n\t\t\tthrow new ArgumentException( \"Pick a valid model to bake.\" );\n\n\t\tvar timer = Stopwatch.StartNew();\n\t\tvar (grid, atlasSize, searchSteps) = settings.Resolve();\n\t\tvar n = Math.Clamp( grid, 2, 32 );\n\t\t// The shader addresses frame (x, y) at (x + u) / n of the atlas, so the atlas must be exactly n frames wide.\n\t\t// 2048 / 12 isn't whole; round the frame down (to keep the atlas a multiple of 4 for block compression) and\n\t\t// shrink the atlas to fit, rather than let the frames drift out of place across the atlas.\n\t\tvar cell = (int)atlasSize / n;\n\t\twhile ( (cell * n) % 4 != 0 )\n\t\t\tcell--;\n\t\tvar atlas = cell * n;\n\t\tvar layout = settings.Layout;\n\n\t\tvar bounds = model.RenderBounds;\n\t\tif ( bounds.Size.Length < 0.001f )\n\t\t\tbounds = model.Bounds;\n\n\t\t// A sphere fitted to the vertices is usually much smaller than the box's half diagonal, so the object fills more\n\t\t// of every frame. Fall back to the box for models without readable vertices.\n\t\tvar vertices = model.GetVertices();\n\t\tvar points = vertices is { Length: > 0 } ? vertices.Select( v => v.Position ).ToArray() : null;\n\t\tvar (center, radius) = points is not null\n\t\t\t? FlatpackMath.BoundingSphere( points )\n\t\t\t: FlatpackMath.BoundingSphere( bounds );\n\n\t\tvar (folder, assetsRoot) = ResolveOutputFolder( model, settings );\n\t\tvar name = SafeName( model );\n\n\t\tvar fitted = settings.FitEachView && points is not null;\n\t\tvar fits = FitFrames( points, center, radius, n, cell, layout, fitted );\n\n\t\tvar albedo = new Color32[atlas * atlas];\n\t\tvar normal = new Color32[atlas * atlas];\n\t\tvar depth = new float[atlas * atlas];\n\t\tvar rao = settings.BakeRoughnessAo ? new Color32[atlas * atlas] : null;\n\t\tvar supersample = Math.Clamp( settings.Supersample, 1, 4 );\n\n\t\tprogress?.Invoke( 0.0f, $\"Baking {name}\" );\n\t\tvar bakedMetal = await CaptureFramesAsync( model, layout, n, cell, atlas, center, radius, fits, albedo, normal, depth, rao, settings.BakeSelfShadow, supersample, progress, cancel );\n\t\tvar seeThrough = PartialCoverageShare( albedo );\n\n\t\t// Fine wire mesh does worse framed: a fence panel seen edge-on squeezes into a sliver of texels, and its card came\n\t\t// out sparser than the real fence from 16 radii (iteration 8: 64 to 68% wrong pixels against 45 unframed). Bake\n\t\t// those views sphere-sized again. The weed tree's twigs (0.42 framed) keep the framing, which helped them a lot.\n\t\tif ( fitted && seeThrough > FineMeshShare )\n\t\t{\n\t\t\tprogress?.Invoke( 0.0f, $\"Baking {name} again, unframed (fine mesh)\" );\n\t\t\tfits = FitFrames( points, center, radius, n, cell, layout, false );\n\t\t\tbakedMetal = await CaptureFramesAsync( model, layout, n, cell, atlas, center, radius, fits, albedo, normal, depth, rao, settings.BakeSelfShadow, supersample, progress, cancel );\n\t\t\tseeThrough = PartialCoverageShare( albedo );\n\t\t}\n\n\t\tcancel.ThrowIfCancellationRequested();\n\t\tprogress?.Invoke( 0.85f, \"Padding edges\" );\n\t\tawait Task.Delay( 1 );\n\n\t\tDilateIntoFrames( albedo, normal, depth, rao, atlas, cell, settings.EdgePadding );\n\t\tvar mipAlphaScales = MipAlphaScales( albedo, atlas );\n\n\t\tcancel.ThrowIfCancellationRequested();\n\t\tprogress?.Invoke( 0.9f, \"Writing files\" );\n\t\tawait Task.Delay( 1 );\n\n\t\tDirectory.CreateDirectory( folder );\n\t\tvar files = new List<string>();\n\n\t\tvar depthBits = settings.DepthBits >= 12 ? 16 : 8;\n\t\tvar albedoFile = WritePng( Path.Combine( folder, $\"{name}_fp_albedo.png\" ), albedo, atlas, files );\n\t\tvar normalFile = WritePng( Path.Combine( folder, $\"{name}_fp_normal.png\" ), normal, atlas, files );\n\t\tvar depthFile = depthBits == 16\n\t\t\t? WritePfm( Path.Combine( folder, $\"{name}_fp_depth.pfm\" ), depth, atlas, files )\n\t\t\t: WritePng( Path.Combine( folder, $\"{name}_fp_depth.png\" ), DepthBytes( depth ), atlas, files );\n\t\tvar framesFile = WriteFrames( Path.Combine( folder, $\"{name}_fp_frames.png\" ), fits, n, files );\n\n\t\t// The coarse map must never read deeper than the depth texture it summarises, or the search would skip a block\n\t\t// it should have searched: BC4 rounds by a few steps, R16 by half of one.\n\t\tvar coarse = CoarseDepth( depth, atlas, cell, n, depthBits == 16 ? 1.0f / 65535.0f : 4.0f / 255.0f, out var coarseSize );\n\t\tvar coarseFile = Path.Combine( folder, $\"{name}_fp_depthmin.pfm\" );\n\t\tFlatpackPfm.WriteGray( coarseFile, coarse, coarseSize, coarseSize );\n\t\tfiles.Add( coarseFile );\n\t\tvar raoFile = rao is null ? null : WritePng( Path.Combine( folder, $\"{name}_fp_rao.png\" ), rao, atlas, files );\n\n\t\tvar zLocked = settings.ZLockedCard || layout == FlatpackLayout.Cylinder;\n\t\tvar materialFile = Path.Combine( folder, $\"{name}_fp.vmat\" );\n\t\tFile.WriteAllText( materialFile, BuildMaterial(\n\t\t\tRelative( assetsRoot, albedoFile ), Relative( assetsRoot, normalFile ), Relative( assetsRoot, depthFile ), depthBits,\n\t\t\tRelative( assetsRoot, framesFile ), Relative( assetsRoot, coarseFile ), raoFile is null ? null : Relative( assetsRoot, raoFile ),\n\t\t\tn, layout, center, radius, zLocked, mipAlphaScales, searchSteps, seeThrough > SeeThroughShare, bakedMetal ) );\n\t\tfiles.Add( materialFile );\n\n\t\tprogress?.Invoke( 0.95f, \"Compiling material\" );\n\n\t\t// Textures first, so the material compile finds them already built.\n\t\tforeach ( var file in files )\n\t\t{\n\t\t\tvar asset = AssetSystem.RegisterFile( file );\n\t\t\tif ( asset is not null )\n\t\t\t\tawait asset.CompileIfNeededAsync( 120.0f );\n\t\t}\n\n\t\tvar materialPath = Relative( assetsRoot, materialFile );\n\t\tvar material = Material.Load( materialPath );\n\n\t\t// The resource: create it once, then overwrite its values on re-bakes so references to it survive.\n\t\tvar impostorFile = Path.Combine( folder, $\"{name}.fpack\" );\n\t\tvar impostorPath = Relative( assetsRoot, impostorFile );\n\t\tvar impostorAsset = AssetSystem.FindByPath( impostorPath );\n\t\tif ( impostorAsset is null )\n\t\t{\n\t\t\timpostorAsset = AssetSystem.CreateResource( \"fpack\", impostorFile );\n\t\t\tawait impostorAsset.CompileIfNeededAsync();\n\t\t}\n\n\t\tif ( !impostorAsset.TryLoadResource<FlatpackImpostor>( out var impostor ) )\n\t\t\tthrow new InvalidOperationException( $\"Couldn't create {impostorPath}.\" );\n\n\t\timpostor.SourceModel = model;\n\t\timpostor.Material = material;\n\t\timpostor.Layout = layout;\n\t\timpostor.GridSize = n;\n\t\timpostor.AtlasSize = atlas;\n\t\timpostor.Center = center;\n\t\timpostor.Radius = radius;\n\t\timpostor.Bounds = bounds;\n\t\timpostor.ThinDetail = mipAlphaScales[1];\n\t\timpostor.QualityLevel = settings.Quality.ToString();\n\t\timpostor.HoldsUpFrom = 0.0f;\n\t\timpostor.SeeThrough = seeThrough;\n\t\timpostorAsset.SaveToDisk( impostor );\n\t\tfiles.Add( impostorFile );\n\n\t\tprogress?.Invoke( 1.0f, $\"Baked {name}\" );\n\n\t\treturn new Result\n\t\t{\n\t\t\tImpostor = impostor,\n\t\t\tImpostorPath = impostorPath,\n\t\t\tMaterialPath = materialPath,\n\t\t\tFiles = files,\n\t\t\tDuration = timer.Elapsed,\n\t\t\tLevel = settings.Quality,\n\t\t};\n\t}\n\n\t/// <summary>\n\t/// Each view's bounds: its outline and depth range from the vertices when <paramref name=\"fitted\"/>, else the whole\n\t/// sphere. Padded by two texels so bilinear filtering at the outline stays inside the frame.\n\t/// </summary>\n\tstatic FlatpackMath.FrameFit[] FitFrames( Vector3[] points, Vector3 center, float radius, int n, int cell, FlatpackLayout layout, bool fitted )\n\t{\n\t\tvar fits = new FlatpackMath.FrameFit[n * n];\n\t\tfor ( var y = 0; y < n; y++ )\n\t\t{\n\t\t\tfor ( var x = 0; x < n; x++ )\n\t\t\t{\n\t\t\t\tfits[y * n + x] = fitted\n\t\t\t\t\t? FlatpackMath.FitFrame( points, center, radius, FlatpackMath.FrameDirection( x, y, n, layout ), cell, 2 )\n\t\t\t\t\t: FlatpackMath.DecodeFrameFit( FlatpackMath.EncodeFrameFit( FlatpackMath.FrameFit.Sphere ) );\n\t\t\t}\n\t\t}\n\t\treturn fits;\n\t}\n\n\t/// <summary>\n\t/// Render every frame and copy it into the CPU atlases. Returns whether metalness went into the roughness/AO atlas.\n\t/// </summary>\n\tstatic async Task<bool> CaptureFramesAsync( Model model, FlatpackLayout layout, int n, int cell, int atlas, Vector3 center, float radius, FlatpackMath.FrameFit[] fits,\n\t\tColor32[] albedo, Color32[] normal, float[] depth, Color32[] rao, bool selfShadow, int supersample, Action<float, string> progress, CancellationToken cancel )\n\t{\n\t\tvar depthMaterial = Material.Create( \"flatpack_bake_depth\", DepthShader, true );\n\t\tselfShadow &= rao is not null;\n\n\t\tvar scene = Scene.CreateEditorScene();\n\t\tscene.Name = \"Flatpack Bake\";\n\n\t\ttry\n\t\t{\n\t\t\tModelRenderer renderer;\n\t\t\tCameraComponent camera;\n\t\t\tDirectionalLight sun = null;\n\t\t\tMetalPass metal = null;\n\n\t\t\tusing ( scene.Push() )\n\t\t\t{\n\t\t\t\tvar source = new GameObject( true, \"source\" );\n\t\t\t\trenderer = source.Components.Create<ModelRenderer>();\n\t\t\t\trenderer.Model = model;\n\n\t\t\t\t// Bake from the most detailed LOD, whatever the frame's screen size would pick.\n\t\t\t\trenderer.LodOverride = 0;\n\n\t\t\t\tvar eye = new GameObject( true, \"camera\" );\n\t\t\t\tcamera = eye.Components.Create<CameraComponent>();\n\t\t\t\tcamera.BackgroundColor = Color.Transparent;\n\t\t\t\tcamera.Orthographic = true;\n\n\t\t\t\tif ( selfShadow )\n\t\t\t\t{\n\t\t\t\t\tvar light = new GameObject( true, \"sky light\" );\n\t\t\t\t\tsun = light.Components.Create<DirectionalLight>();\n\t\t\t\t\tsun.LightColor = Color.White;\n\t\t\t\t\tsun.Shadows = true;\n\t\t\t\t}\n\n\t\t\t\tif ( rao is not null )\n\t\t\t\t\tmetal = MetalPass.Create( renderer );\n\t\t\t}\n\n\t\t\tvar frame = new Bitmap( cell * supersample, cell * supersample, false );\n\t\t\tvar total = n * n;\n\t\t\tvar sliceTimer = Stopwatch.StartNew();\n\n\t\t\tfor ( var y = 0; y < n; y++ )\n\t\t\t{\n\t\t\t\tfor ( var x = 0; x < n; x++ )\n\t\t\t\t{\n\t\t\t\t\tcancel.ThrowIfCancellationRequested();\n\n\t\t\t\t\tvar dir = FlatpackMath.FrameDirection( x, y, n, layout );\n\t\t\t\t\tvar fit = fits[y * n + x];\n\n\t\t\t\t\tColor32[] colorPixels, normalPixels, roughPixels = null, aoPixels = null;\n\t\t\t\t\tfloat[] depthPixels;\n\t\t\t\t\tbyte[] visibility = null, metalness = null;\n\n\t\t\t\t\tusing ( scene.Push() )\n\t\t\t\t\t{\n\t\t\t\t\t\tFrameCamera( camera, center, radius, dir, fit );\n\t\t\t\t\t\tscene.EditorTick( 0.0f, 0.0f );\n\n\t\t\t\t\t\tcolorPixels = Capture( camera, renderer, frame, SceneCameraDebugMode.Albedo, null );\n\t\t\t\t\t\tnormalPixels = Capture( camera, renderer, frame, SceneCameraDebugMode.NormalMap, null );\n\t\t\t\t\t\tdepthPixels = DecodeDepth( Capture( camera, renderer, frame, SceneCameraDebugMode.Albedo, depthMaterial ) );\n\n\t\t\t\t\t\tif ( rao is not null )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\troughPixels = Capture( camera, renderer, frame, SceneCameraDebugMode.Roughness, null );\n\t\t\t\t\t\t\taoPixels = Capture( camera, renderer, frame, SceneCameraDebugMode.AmbientOcclusion, null );\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif ( selfShadow )\n\t\t\t\t\t\t\tvisibility = CaptureSkyVisibility( scene, camera, renderer, sun, frame, normalPixels );\n\n\t\t\t\t\t\tif ( metal is not null )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tif ( sun is not null ) sun.Enabled = false;\n\t\t\t\t\t\t\tmetalness = metal.Capture( scene, camera, renderer, frame );\n\t\t\t\t\t\t\tif ( sun is not null ) sun.Enabled = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif ( supersample > 1 )\n\t\t\t\t\t\tDownsample( cell, supersample, ref colorPixels, ref normalPixels, ref depthPixels, ref roughPixels, ref aoPixels, ref visibility, ref metalness );\n\n\t\t\t\t\tBlitFrame( x, y, cell, atlas, colorPixels, normalPixels, depthPixels, roughPixels, aoPixels, visibility, metalness, albedo, normal, depth, rao );\n\n\t\t\t\t\tvar done = y * n + x + 1;\n\t\t\t\t\tprogress?.Invoke( done / (float)total * 0.85f, $\"Frame {done} of {total}\" );\n\n\t\t\t\t\t// Hand the main thread back now and then so the editor stays responsive.\n\t\t\t\t\tif ( sliceTimer.ElapsedMilliseconds > 30 )\n\t\t\t\t\t{\n\t\t\t\t\t\tawait Task.Delay( 1 );\n\t\t\t\t\t\tsliceTimer.Restart();\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn metal is not null;\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tscene.Destroy();\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Directions the sky-visibility pass lights the model from: one overhead and two rings, spread over the upper\n\t/// hemisphere. Fixed in object space, so every frame sees the same shadows and blending between frames stays seamless.\n\t/// </summary>\n\tstatic readonly Angles[] SkyLights =\n\t{\n\t\tnew( 85, 0, 0 ),\n\t\tnew( 60, 0, 0 ), new( 60, 120, 0 ), new( 60, 240, 0 ),\n\t\tnew( 30, 45, 0 ), new( 30, 135, 0 ), new( 30, 225, 0 ), new( 30, 315, 0 ),\n\t};\n\n\t/// <summary>\n\t/// How much of the sky each texel sees, 0..255: diffuse light with shadows over diffuse light without, both summed over\n\t/// <see cref=\"SkyLights\"/>. This is the self-shadowing a dense model (a tree's inner branches) has and a card can't\n\t/// work out live; the shader uses it to darken ambient light and part of the albedo.\n\t/// </summary>\n\t/// <remarks>\n\t/// Eight lights average out to soft occlusion; with only a few, a surface facing one light gets that light's hard\n\t/// shadow and turns black. Ratios are taken in linear light.\n\t/// </remarks>\n\tstatic byte[] CaptureSkyVisibility( Scene scene, CameraComponent camera, ModelRenderer renderer, DirectionalLight sun, Bitmap target, Color32[] normals )\n\t{\n\t\tvar count = target.Width * target.Height;\n\t\tvar lit = new float[count];\n\t\tvar open = new float[count];\n\n\t\tforeach ( var angles in SkyLights )\n\t\t{\n\t\t\tsun.WorldRotation = angles;\n\n\t\t\tforeach ( var shadowed in new[] { true, false } )\n\t\t\t{\n\t\t\t\tsun.Shadows = shadowed;\n\t\t\t\tscene.EditorTick( 0.0f, 0.0f );\n\n\t\t\t\t// Render twice: the first render after moving or toggling the light can still use the old shadow map.\n\t\t\t\tCapture( camera, renderer, target, SceneCameraDebugMode.Diffuse, null );\n\t\t\t\tvar pixels = Capture( camera, renderer, target, SceneCameraDebugMode.Diffuse, null );\n\n\t\t\t\tvar sum = shadowed ? lit : open;\n\t\t\t\tfor ( var i = 0; i < count; i++ )\n\t\t\t\t\tsum[i] += Linear( pixels[i].r );\n\t\t\t}\n\t\t}\n\n\t\t// Shadow-map bias and terminator softness keep even fully open texels a little under 1, and that shifts from frame\n\t\t// to frame. Normalise: the well-lit texels of this frame (95th percentile) count as fully visible. Capped, so a\n\t\t// frame that really is mostly in shadow isn't brightened.\n\t\tvar ratio = new float[count];\n\t\tvar samples = new List<float>( count );\n\t\tvar openMax = 0.0f;\n\t\tfor ( var i = 0; i < count; i++ )\n\t\t\topenMax = MathF.Max( openMax, open[i] );\n\n\t\tfor ( var i = 0; i < count; i++ )\n\t\t{\n\t\t\tratio[i] = open[i] > 0.0f ? Math.Clamp( lit[i] / open[i], 0.0f, 1.0f ) : 1.0f;\n\t\t\tif ( normals[i].a > 0 && open[i] > openMax * 0.2f )\n\t\t\t\tsamples.Add( ratio[i] );\n\t\t}\n\n\t\tsamples.Sort();\n\t\tvar reference = samples.Count > 16 ? samples[(int)(samples.Count * 0.95f)] : 1.0f;\n\t\treference = Math.Clamp( reference, 0.75f, 1.0f );\n\n\t\tvar visibility = new byte[count];\n\t\tfor ( var i = 0; i < count; i++ )\n\t\t{\n\t\t\t// Texels the sky barely reaches (undersides) carry little information; fade them toward fully visible.\n\t\t\tvar known = openMax > 0.0f ? Math.Clamp( open[i] / (openMax * 0.2f), 0.0f, 1.0f ) : 0.0f;\n\t\t\tvar v = Math.Clamp( ratio[i] / reference, 0.0f, 1.0f );\n\t\t\tvisibility[i] = (byte)Math.Clamp( (int)MathF.Round( (v * known + (1.0f - known)) * 255.0f ), 0, 255 );\n\t\t}\n\n\t\treturn visibility;\n\t}\n\n\t/// <summary>\n\t/// The diffuse lighting view writes light through an sRGB target; ratios only make sense in linear light.\n\t/// </summary>\n\tstatic float Linear( byte value )\n\t{\n\t\tvar c = value / 255.0f;\n\t\treturn c <= 0.04045f ? c / 12.92f : MathF.Pow( (c + 0.055f) / 1.055f, 2.4f );\n\t}\n\n\t/// <summary>\n\t/// Point the bake camera at one frame: orthographic, framing exactly the frame's fitted image rectangle (wider or\n\t/// taller than square when the object is), with the near plane on its nearest surface.\n\t/// </summary>\n\tstatic void FrameCamera( CameraComponent camera, Vector3 center, float radius, Vector3 dir, FlatpackMath.FrameFit fit )\n\t{\n\t\tFlatpackMath.FrameBasis( dir, out var right, out var up );\n\n\t\tvar size = (fit.Max - fit.Min) * (2.0f * radius);\n\t\tvar mid = (fit.Min + fit.Max) * 0.5f - new Vector2( 0.5f, 0.5f );\n\n\t\t// Image v runs down, so the rectangle's centre is up by -v. The camera stays two radii out, outside the sphere.\n\t\tvar distance = radius * 2.0f;\n\t\tcamera.WorldPosition = center + right * (mid.x * 2.0f * radius) - up * (mid.y * 2.0f * radius) + dir * distance;\n\t\tcamera.WorldRotation = FlatpackMath.FrameRotation( dir );\n\n\t\t// Depth 0..1 spans one bounding diameter back from the nearest surface, not just to the furthest one: the background\n\t\t// then sits at least a diameter behind the object, where the shader's search never reaches, even in mips that\n\t\t// average an outline with it. 16 bits over a diameter is still 256 times finer than the 8 bits it replaces.\n\t\tvar zNear = distance - fit.Near * radius;\n\t\tvar zFar = zNear + 2.0f * radius;\n\t\tcamera.OrthographicHeight = size.y;\n\t\tcamera.ZNear = zNear;\n\t\tcamera.ZFar = zFar;\n\n\t\t// The engine's own orthographic camera is square for a square target; this is the same matrix (checked against\n\t\t// CameraComponent.ProjectionMatrix in the editor) with its own width, so a tall object fills the frame both ways.\n\t\tcamera.CustomProjectionMatrix = new Matrix(\n\t\t\t2.0f / size.x, 0, 0, 0,\n\t\t\t0, 2.0f / size.y, 0, 0,\n\t\t\t0, 0, -1.0f / (zFar - zNear), -zNear / (zFar - zNear),\n\t\t\t0, 0, 0, 1 );\n\t}\n\n\t/// <summary>\n\t/// The depth bake's red and green bytes back to one 0..1 depth: red is whole 1/255 steps, green the fraction of a step.\n\t/// </summary>\n\tstatic float[] DecodeDepth( Color32[] pixels )\n\t{\n\t\tvar depth = new float[pixels.Length];\n\t\tfor ( var i = 0; i < pixels.Length; i++ )\n\t\t\tdepth[i] = Math.Clamp( (pixels[i].r + pixels[i].g / 255.0f) / 255.0f, 0.0f, 1.0f );\n\t\treturn depth;\n\t}\n\n\tstatic Color32[] Capture( CameraComponent camera, ModelRenderer renderer, Bitmap target, SceneCameraDebugMode mode, Material overrideMaterial )\n\t{\n\t\tcamera.DebugMode = mode;\n\t\trenderer.MaterialOverride = overrideMaterial;\n\t\tcamera.RenderToBitmap( target );\n\t\treturn target.GetPixels32();\n\t}\n\n\t/// <summary>\n\t/// Bakes metalness. s&amp;box has no metalness debug view and materials store it in different ways (a texture, a\n\t/// constant, packed channels), so this solves for it instead of reading it: the Reflect view of the real materials\n\t/// against copies forced to metalness 0 and 1, all lit by one light from the camera. Per texel,\n\t/// metal = (real - dielectric) / (metal - dielectric).\n\t/// </summary>\n\tsealed class MetalPass\n\t{\n\t\tMaterial[] _original, _dielectric, _metal;\n\t\tDirectionalLight _light;\n\n\t\t/// <summary>A pass for <paramref name=\"renderer\"/>'s model, or null when none of its materials can be metal.</summary>\n\t\tpublic static MetalPass Create( ModelRenderer renderer )\n\t\t{\n\t\t\tvar count = renderer.Materials.Count;\n\t\t\tvar pass = new MetalPass { _original = new Material[count], _dielectric = new Material[count], _metal = new Material[count] };\n\t\t\tvar any = false;\n\t\t\tfor ( var i = 0; i < count; i++ )\n\t\t\t{\n\t\t\t\tvar source = renderer.Materials.GetOriginal( i );\n\t\t\t\tpass._original[i] = source;\n\t\t\t\tif ( source is null )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tany |= source.GetFeature( \"F_METALNESS_TEXTURE\" ) != 0 || source.GetVector4( \"g_flMetalness\" ).x > 0.0f;\n\t\t\t\tpass._dielectric[i] = Forced( source, 0.0f, i );\n\t\t\t\tpass._metal[i] = Forced( source, 1.0f, i );\n\t\t\t}\n\n\t\t\tif ( !any )\n\t\t\t\treturn null;\n\n\t\t\tpass._light = new GameObject( true, \"metal light\" ).Components.Create<DirectionalLight>();\n\t\t\tpass._light.LightColor = Color.White;\n\t\t\tpass._light.Shadows = false;\n\t\t\tpass._light.Enabled = false;\n\t\t\treturn pass;\n\t\t}\n\n\t\tstatic Material Forced( Material source, float metalness, int slot )\n\t\t{\n\t\t\tvar copy = source.CreateCopy( $\"flatpack_metal_{metalness:0}_{slot}\" );\n\t\t\tcopy.SetFeature( \"F_METALNESS_TEXTURE\", 0 );\n\t\t\tcopy.Set( \"g_flMetalness\", metalness );\n\t\t\treturn copy;\n\t\t}\n\n\t\t/// <summary>Metalness per pixel of <paramref name=\"target\"/>, 0..255. Call inside the bake scene.</summary>\n\t\tpublic byte[] Capture( Scene scene, CameraComponent camera, ModelRenderer renderer, Bitmap target )\n\t\t{\n\t\t\t_light.WorldRotation = camera.WorldRotation;\n\t\t\t_light.Enabled = true;\n\t\t\tvar real = Render( scene, camera, renderer, target, _original );\n\t\t\tvar low = Render( scene, camera, renderer, target, _dielectric );\n\t\t\tvar high = Render( scene, camera, renderer, target, _metal );\n\t\t\t_light.Enabled = false;\n\t\t\trenderer.ClearMaterialOverrides();\n\n\t\t\tvar w = target.Width;\n\t\t\tvar h = target.Height;\n\t\t\tvar count = real.Length;\n\t\t\tvar value = new float[count];\n\t\t\tvar known = new bool[count];\n\t\t\tfor ( var i = 0; i < count; i++ )\n\t\t\t{\n\t\t\t\tif ( real[i].a < 128 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tfloat r = Brightness( real[i] ), d = Brightness( low[i] ), m = Brightness( high[i] );\n\n\t\t\t\t// Where metal and dielectric reflect alike (dark colours, grazing angles) the solve has nothing to go on;\n\t\t\t\t// metalness barely changes the look there anyway. Those texels borrow their neighbours' value below.\n\t\t\t\tif ( MathF.Abs( m - d ) < 6.0f )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvalue[i] = Math.Clamp( (r - d) / (m - d), 0.0f, 1.0f );\n\t\t\t\tknown[i] = true;\n\t\t\t}\n\n\t\t\t// A few rounds of filling unknown texels from known neighbours; anything still unknown is non-metal.\n\t\t\tfor ( var round = 0; round < 6; round++ )\n\t\t\t{\n\t\t\t\tvar next = (bool[])known.Clone();\n\t\t\t\tfor ( var i = 0; i < count; i++ )\n\t\t\t\t{\n\t\t\t\t\tif ( known[i] || real[i].a < 128 )\n\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\tint x = i % w, y = i / w, n = 0;\n\t\t\t\t\tvar sum = 0.0f;\n\t\t\t\t\tfor ( var dy = -1; dy <= 1; dy++ )\n\t\t\t\t\t{\n\t\t\t\t\t\tfor ( var dx = -1; dx <= 1; dx++ )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tint nx = x + dx, ny = y + dy;\n\t\t\t\t\t\t\tif ( nx < 0 || ny < 0 || nx >= w || ny >= h ) continue;\n\t\t\t\t\t\t\tvar j = ny * w + nx;\n\t\t\t\t\t\t\tif ( !known[j] ) continue;\n\t\t\t\t\t\t\tsum += value[j];\n\t\t\t\t\t\t\tn++;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif ( n == 0 )\n\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\tvalue[i] = sum / n;\n\t\t\t\t\tnext[i] = true;\n\t\t\t\t}\n\t\t\t\tknown = next;\n\t\t\t}\n\n\t\t\tvar bytes = new byte[count];\n\t\t\tfor ( var i = 0; i < count; i++ )\n\t\t\t\tbytes[i] = (byte)Math.Clamp( (int)MathF.Round( value[i] * 255.0f ), 0, 255 );\n\t\t\treturn bytes;\n\t\t}\n\n\t\tstatic Color32[] Render( Scene scene, CameraComponent camera, ModelRenderer renderer, Bitmap target, Material[] materials )\n\t\t{\n\t\t\tfor ( var i = 0; i < materials.Length; i++ )\n\t\t\t{\n\t\t\t\tif ( materials[i] is not null )\n\t\t\t\t\trenderer.Materials.SetOverride( i, materials[i] );\n\t\t\t}\n\t\t\trenderer.Materials.Apply();\n\t\t\tscene.EditorTick( 0.0f, 0.0f );\n\t\t\treturn FlatpackBaker.Capture( camera, renderer, target, SceneCameraDebugMode.Reflect, null );\n\t\t}\n\n\t\tstatic float Brightness( Color32 c ) => (c.r + c.g + c.b) / 3.0f;\n\t}\n\n\t/// <summary>\n\t/// Average a supersampled frame down to <paramref name=\"cell\"/> texels a side. Alpha becomes the share of covered\n\t/// samples; colour, normal, depth and the material channels average over the covered samples only, so the empty\n\t/// background doesn't darken thin parts.\n\t/// </summary>\n\tstatic void Downsample( int cell, int ss, ref Color32[] color, ref Color32[] normal, ref float[] depth, ref Color32[] rough, ref Color32[] ao, ref byte[] visibility, ref byte[] metal )\n\t{\n\t\tvar size = cell * ss;\n\t\tvar outColor = new Color32[cell * cell];\n\t\tvar outNormal = new Color32[cell * cell];\n\t\tvar outDepth = new float[cell * cell];\n\t\tvar outRough = rough is null ? null : new Color32[cell * cell];\n\t\tvar outAo = ao is null ? null : new Color32[cell * cell];\n\t\tvar outVis = visibility is null ? null : new byte[cell * cell];\n\t\tvar outMetal = metal is null ? null : new byte[cell * cell];\n\n\t\tfor ( var y = 0; y < cell; y++ )\n\t\t{\n\t\t\tfor ( var x = 0; x < cell; x++ )\n\t\t\t{\n\t\t\t\tfloat r = 0, g = 0, b = 0, a = 0, nx = 0, ny = 0, nz = 0, d = 0, ro = 0, oc = 0, vi = 0, me = 0;\n\t\t\t\tvar covered = 0;\n\n\t\t\t\tfor ( var sy = 0; sy < ss; sy++ )\n\t\t\t\t{\n\t\t\t\t\tfor ( var sx = 0; sx < ss; sx++ )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar i = (y * ss + sy) * size + x * ss + sx;\n\t\t\t\t\t\tvar c = color[i];\n\n\t\t\t\t\t\t// The albedo view writes the material's raw opacity. Alpha-tested textures keep their small mips\n\t\t\t\t\t\t// above the cut on purpose, so averaging that raw value overstates thin detail (a fence panel that's\n\t\t\t\t\t\t// 12% wire read as 44%). Cut each sample the way the game cuts the real model, then average.\n\t\t\t\t\t\tif ( c.a < AlphaTestCut )\n\t\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\t\ta += 255;\n\t\t\t\t\t\tcovered++;\n\t\t\t\t\t\tr += c.r; g += c.g; b += c.b;\n\t\t\t\t\t\tnx += normal[i].r / 127.5f - 1.0f; ny += normal[i].g / 127.5f - 1.0f; nz += normal[i].b / 127.5f - 1.0f;\n\t\t\t\t\t\td += depth[i];\n\t\t\t\t\t\tif ( rough is not null ) ro += rough[i].r;\n\t\t\t\t\t\tif ( ao is not null ) oc += ao[i].r;\n\t\t\t\t\t\tif ( visibility is not null ) vi += visibility[i];\n\t\t\t\t\t\tif ( metal is not null ) me += metal[i];\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tvar o = y * cell + x;\n\t\t\t\tif ( covered == 0 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tbyte B( float v ) => (byte)Math.Clamp( (int)MathF.Round( v ), 0, 255 );\n\t\t\t\tvar alpha = B( a / (ss * ss) );\n\t\t\t\toutColor[o] = new Color32( B( r / covered ), B( g / covered ), B( b / covered ), Math.Max( alpha, (byte)1 ) );\n\n\t\t\t\tvar len = MathF.Sqrt( nx * nx + ny * ny + nz * nz );\n\t\t\t\tif ( len < 1e-4f ) len = 1.0f;\n\t\t\t\toutNormal[o] = new Color32( B( (nx / len + 1.0f) * 127.5f ), B( (ny / len + 1.0f) * 127.5f ), B( (nz / len + 1.0f) * 127.5f ), 255 );\n\n\t\t\t\toutDepth[o] = d / covered;\n\t\t\t\tif ( outRough is not null ) { var v = B( ro / covered ); outRough[o] = new Color32( v, v, v, 255 ); }\n\t\t\t\tif ( outAo is not null ) { var v = B( oc / covered ); outAo[o] = new Color32( v, v, v, 255 ); }\n\t\t\t\tif ( outVis is not null ) outVis[o] = B( vi / covered );\n\t\t\t\tif ( outMetal is not null ) outMetal[o] = B( me / covered );\n\t\t\t}\n\t\t}\n\n\t\tcolor = outColor; normal = outNormal; depth = outDepth; rough = outRough; ao = outAo; visibility = outVis; metal = outMetal;\n\t}\n\n\t/// <summary>\n\t/// Copy one frame's passes into the atlases. Uncovered texels stay fully clear (depth at the far plane) for the\n\t/// dilation pass.\n\t/// </summary>\n\tstatic void BlitFrame( int fx, int fy, int cell, int atlas, Color32[] color, Color32[] normal, float[] depth, Color32[] rough, Color32[] ao,\n\t\tbyte[] visibility, byte[] metal, Color32[] albedoAtlas, Color32[] normalAtlas, float[] depthAtlas, Color32[] raoAtlas )\n\t{\n\t\tfor ( var py = 0; py < cell; py++ )\n\t\t{\n\t\t\tvar row = (fy * cell + py) * atlas + fx * cell;\n\n\t\t\tfor ( var px = 0; px < cell; px++ )\n\t\t\t{\n\t\t\t\tvar src = py * cell + px;\n\t\t\t\tvar dst = row + px;\n\t\t\t\tvar c = color[src];\n\n\t\t\t\tif ( c.a == 0 )\n\t\t\t\t{\n\t\t\t\t\talbedoAtlas[dst] = default;\n\t\t\t\t\tnormalAtlas[dst] = new Color32( 128, 128, 255, 255 );\n\t\t\t\t\tdepthAtlas[dst] = 1.0f;\n\t\t\t\t\tif ( raoAtlas is not null ) raoAtlas[dst] = default;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\talbedoAtlas[dst] = c;\n\t\t\t\tnormalAtlas[dst] = new Color32( normal[src].r, normal[src].g, normal[src].b, 255 );\n\t\t\t\tdepthAtlas[dst] = depth[src];\n\n\t\t\t\t// Metalness rides in alpha as 1 + metal x 254 (never 0, which would wipe the colour; see the shader).\n\t\t\t\tif ( raoAtlas is not null )\n\t\t\t\t\traoAtlas[dst] = new Color32( rough[src].r, ao[src].r, visibility?[src] ?? 255, metal is null ? (byte)255 : (byte)(1 + metal[src] * 254 / 255) );\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Grow every frame's colours outward from its silhouette, staying inside the frame, so bilinear filtering and\n\t/// mips near the edge read the object's own colour instead of the empty background.\n\t/// </summary>\n\t/// <remarks>\n\t/// One multi-source breadth-first fill over the whole atlas: each empty texel takes the values of the nearest\n\t/// covered texel (by steps) in its own frame. Albedo alpha on grown texels is 1/255, far below the alpha clip,\n\t/// but non-zero so the colour survives the PNG round trip.\n\t/// </remarks>\n\tstatic void DilateIntoFrames( Color32[] albedo, Color32[] normal, float[] depth, Color32[] rao, int atlas, int cell, int padding )\n\t{\n\t\tvar maxSteps = padding <= 0 ? int.MaxValue : padding;\n\t\tvar source = new int[albedo.Length];\n\t\tvar steps = new int[albedo.Length];\n\t\tvar queue = new Queue<int>( albedo.Length / 4 );\n\n\t\tfor ( var i = 0; i < albedo.Length; i++ )\n\t\t{\n\t\t\tif ( albedo[i].a > 0 )\n\t\t\t{\n\t\t\t\tsource[i] = i;\n\t\t\t\tqueue.Enqueue( i );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tsource[i] = -1;\n\t\t\t}\n\t\t}\n\n\t\twhile ( queue.Count > 0 )\n\t\t{\n\t\t\tvar i = queue.Dequeue();\n\t\t\tif ( steps[i] >= maxSteps )\n\t\t\t\tcontinue;\n\n\t\t\tvar x = i % atlas;\n\t\t\tvar y = i / atlas;\n\n\t\t\t// Neighbours in the same frame only, never across a frame border.\n\t\t\tif ( x % cell > 0 ) Grow( i, i - 1 );\n\t\t\tif ( x % cell < cell - 1 ) Grow( i, i + 1 );\n\t\t\tif ( y % cell > 0 ) Grow( i, i - atlas );\n\t\t\tif ( y % cell < cell - 1 ) Grow( i, i + atlas );\n\t\t}\n\n\t\tvoid Grow( int from, int to )\n\t\t{\n\t\t\tif ( source[to] >= 0 )\n\t\t\t\treturn;\n\n\t\t\tvar s = source[from];\n\t\t\tsource[to] = s;\n\t\t\tsteps[to] = steps[from] + 1;\n\t\t\tqueue.Enqueue( to );\n\n\t\t\tvar c = albedo[s];\n\t\t\talbedo[to] = new Color32( c.r, c.g, c.b, 1 );\n\n\t\t\t// Grown texels keep the neighbour's normal but sit at the far plane, so the shader's depth search passes\n\t\t\t// straight through the background instead of stopping on borrowed depth and drawing a ghost.\n\t\t\tnormal[to] = normal[s];\n\t\t\tdepth[to] = 1.0f;\n\t\t\tif ( rao is not null ) rao[to] = rao[s];\n\t\t}\n\t}\n\n\tstatic string WritePng( string path, Color32[] pixels, int size, List<string> files ) => WritePng( path, pixels, size, size, files );\n\n\tstatic string WritePng( string path, Color32[] pixels, int width, int height, List<string> files )\n\t{\n\t\tusing var bitmap = new Bitmap( width, height, false );\n\t\tvar colors = new Color[pixels.Length];\n\t\tfor ( var i = 0; i < pixels.Length; i++ )\n\t\t\tcolors[i] = pixels[i];\n\n\t\tbitmap.SetPixels( colors );\n\t\tFile.WriteAllBytes( path, bitmap.ToPng() );\n\t\tfiles.Add( path );\n\t\treturn path;\n\t}\n\n\t/// <summary>The 16-bit depth atlas, as a float image the texture compiler turns into R16.</summary>\n\tstatic string WritePfm( string path, float[] depth, int size, List<string> files )\n\t{\n\t\tFlatpackPfm.WriteGray( path, depth, size, size );\n\t\tfiles.Add( path );\n\t\treturn path;\n\t}\n\n\t/// <summary>Depth rounded to bytes, for the 8-bit (BC4) depth texture.</summary>\n\tstatic Color32[] DepthBytes( float[] depth )\n\t{\n\t\tvar bytes = new Color32[depth.Length];\n\t\tfor ( var i = 0; i < depth.Length; i++ )\n\t\t{\n\t\t\tvar v = (byte)Math.Clamp( (int)MathF.Round( depth[i] * 255.0f ), 0, 255 );\n\t\t\tbytes[i] = new Color32( v, v, v, 255 );\n\t\t}\n\t\treturn bytes;\n\t}\n\n\t/// <summary>Texels per side of one coarse depth block (written into the material as <c>g_flFlatpackCoarseBlock</c>).</summary>\n\tpublic const int CoarseBlock = 16;\n\n\t/// <summary>\n\t/// The coarse depth map: for each <see cref=\"CoarseBlock\"/> x <see cref=\"CoarseBlock\"/> texel block of each frame, the\n\t/// nearest depth in it (the smallest value) less <paramref name=\"margin\"/>, laid out per frame like the atlas\n\t/// (frame x, y at blocks x, y of a <paramref name=\"size\"/>-wide square). The shader's search skips every block whose\n\t/// nearest surface the ray never gets down to, and marches the rest a texel at a time.\n\t/// </summary>\n\tstatic float[] CoarseDepth( float[] depth, int atlas, int cell, int n, float margin, out int size )\n\t{\n\t\tvar blocks = (cell + CoarseBlock - 1) / CoarseBlock;\n\t\tsize = blocks * n;\n\t\tvar coarse = new float[size * size];\n\n\t\tfor ( var fy = 0; fy < n; fy++ )\n\t\tfor ( var fx = 0; fx < n; fx++ )\n\t\tfor ( var by = 0; by < blocks; by++ )\n\t\tfor ( var bx = 0; bx < blocks; bx++ )\n\t\t{\n\t\t\tvar nearest = 1.0f;\n\t\t\tfor ( var y = by * CoarseBlock; y < Math.Min( (by + 1) * CoarseBlock, cell ); y++ )\n\t\t\t{\n\t\t\t\tvar row = (fy * cell + y) * atlas + fx * cell;\n\t\t\t\tfor ( var x = bx * CoarseBlock; x < Math.Min( (bx + 1) * CoarseBlock, cell ); x++ )\n\t\t\t\t\tnearest = MathF.Min( nearest, depth[row + x] );\n\t\t\t}\n\n\t\t\tcoarse[(fy * blocks + by) * size + fx * blocks + bx] = MathF.Max( 0.0f, nearest - margin );\n\t\t}\n\n\t\treturn coarse;\n\t}\n\n\t/// <summary>\n\t/// The frame lookup texture: two texels per frame (see <see cref=\"FlatpackMath.EncodeFrameFit\"/>), image rectangles in\n\t/// the left half, depth ranges <see cref=\"FlatpackMath.FrameFitColumns\"/> texels to the right.\n\t/// </summary>\n\tstatic string WriteFrames( string path, FlatpackMath.FrameFit[] fits, int n, List<string> files )\n\t{\n\t\tconst int width = FlatpackMath.FrameFitColumns * 2;\n\t\tconst int height = FlatpackMath.FrameFitColumns;\n\t\tvar pixels = new Color32[width * height];\n\t\tfor ( var i = 0; i < pixels.Length; i++ )\n\t\t\tpixels[i] = new Color32( 0, 0, 255, 255 );\n\n\t\tfor ( var y = 0; y < n; y++ )\n\t\t{\n\t\t\tfor ( var x = 0; x < n; x++ )\n\t\t\t{\n\t\t\t\tvar (rect, range) = FlatpackMath.EncodeFrameFit( fits[y * n + x] );\n\t\t\t\tpixels[y * width + x] = rect;\n\t\t\t\tpixels[y * width + x + FlatpackMath.FrameFitColumns] = range;\n\t\t\t}\n\t\t}\n\n\t\treturn WritePng( path, pixels, width, height, files );\n\t}\n\n\t/// <summary>\n\t/// Per-mip alpha multipliers that keep the share of covered texels the same as at full resolution.\n\t/// </summary>\n\t/// <remarks>\n\t/// Mipmaps average a thin part's alpha with the empty space around it. Past a mip or two, spindles, wires and leaf\n\t/// tips fall below the 0.5 clip and the card loses them (or they sparkle). For each mip level we find the alpha\n\t/// that the same share of texels exceeds as were above 0.5 at full size, and store the factor that lifts it back to\n\t/// 0.5. A fixed boost instead over-fills foliage and fences into solid blobs. Mips are approximated with a 2x2 box\n\t/// filter, as the texture compiler builds them.\n\t/// </remarks>\n\tstatic float[] MipAlphaScales( Color32[] albedo, int size )\n\t{\n\t\tconst int levels = 8;\n\t\tvar scales = new float[levels];\n\t\tvar alpha = new float[albedo.Length];\n\t\tvar covered = 0;\n\t\tfor ( var i = 0; i < albedo.Length; i++ )\n\t\t{\n\t\t\talpha[i] = albedo[i].a / 255.0f;\n\t\t\tif ( alpha[i] > 0.5f ) covered++;\n\t\t}\n\n\t\tvar share = covered / (float)albedo.Length;\n\t\tvar w = size;\n\t\tvar h = size;\n\n\t\tfor ( var level = 0; level < levels; level++ )\n\t\t{\n\t\t\tvar nw = Math.Max( 1, w / 2 );\n\t\t\tvar nh = Math.Max( 1, h / 2 );\n\t\t\tvar next = new float[nw * nh];\n\t\t\tfor ( var y = 0; y < nh; y++ )\n\t\t\t{\n\t\t\t\tfor ( var x = 0; x < nw; x++ )\n\t\t\t\t{\n\t\t\t\t\tvar x0 = Math.Min( x * 2, w - 1 ); var x1 = Math.Min( x * 2 + 1, w - 1 );\n\t\t\t\t\tvar y0 = Math.Min( y * 2, h - 1 ); var y1 = Math.Min( y * 2 + 1, h - 1 );\n\t\t\t\t\tnext[y * nw + x] = (alpha[y0 * w + x0] + alpha[y0 * w + x1] + alpha[y1 * w + x0] + alpha[y1 * w + x1]) * 0.25f;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\talpha = next; w = nw; h = nh;\n\n\t\t\t// The alpha value with `share` of this mip's texels above it.\n\t\t\tvar sorted = (float[])alpha.Clone();\n\t\t\tArray.Sort( sorted );\n\t\t\tvar index = Math.Clamp( (int)((1.0f - share) * sorted.Length), 0, sorted.Length - 1 );\n\t\t\tvar threshold = sorted[index];\n\t\t\tscales[level] = threshold > 0.01f ? Math.Clamp( 0.5f / threshold, 1.0f, 4.0f ) : 1.0f;\n\t\t}\n\n\t\treturn scales;\n\t}\n\n\tstatic string BuildMaterial( string albedo, string normal, string depth, int depthBits, string frames, string coarse, string rao, int n, FlatpackLayout layout, Vector3 center, float radius,\n\t\tbool zLocked, float[] mipAlphaScales, int searchSteps, bool seeThrough, bool bakedMetal )\n\t{\n\t\tstring F( float v ) => v.ToString( \"0.000000\", CultureInfo.InvariantCulture );\n\n\t\tvar sb = new StringBuilder();\n\t\tsb.AppendLine( \"// Flatpack impostor material. Generated by the Flatpack baker (Anakin Studio); re-baking overwrites it.\" );\n\t\tsb.AppendLine( \"Layer0\" );\n\t\tsb.AppendLine( \"{\" );\n\t\tsb.AppendLine( $\"\\tshader \\\"{ImpostorShader}\\\"\" );\n\t\tsb.AppendLine( $\"\\tTextureAlbedo \\\"{albedo}\\\"\" );\n\t\tsb.AppendLine( $\"\\tTextureNormal \\\"{normal}\\\"\" );\n\t\tsb.AppendLine( depthBits == 16 ? $\"\\tTextureDepth \\\"{depth}\\\"\" : $\"\\tTextureDepthBC4 \\\"{depth}\\\"\" );\n\t\tsb.AppendLine( $\"\\tTextureFrames \\\"{frames}\\\"\" );\n\t\tsb.AppendLine( $\"\\tTextureDepthMin \\\"{coarse}\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_flFlatpackCoarse \\\"{F( 1 )}\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_flFlatpackCoarseBlock \\\"{F( CoarseBlock )}\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_flFlatpackFramed \\\"{F( 1 )}\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_flFlatpackDepthBits \\\"{F( depthBits )}\\\"\" );\n\t\tif ( rao is not null )\n\t\t\tsb.AppendLine( $\"\\tTextureRoughnessAo \\\"{rao}\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_flFlatpackGrid \\\"{F( n )}\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_flFlatpackMode \\\"{F( (int)layout )}\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_vFlatpackCenter \\\"[{F( center.x )} {F( center.y )} {F( center.z )}]\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_flFlatpackRadius \\\"{F( radius )}\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_flFlatpackZLocked \\\"{F( zLocked ? 1 : 0 )}\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_vFlatpackMipAlpha0 \\\"[{F( mipAlphaScales[0] )} {F( mipAlphaScales[1] )} {F( mipAlphaScales[2] )} {F( mipAlphaScales[3] )}]\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_vFlatpackMipAlpha1 \\\"[{F( mipAlphaScales[4] )} {F( mipAlphaScales[5] )} {F( mipAlphaScales[6] )} {F( mipAlphaScales[7] )}]\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_flFlatpackSearchSteps \\\"{F( Math.Clamp( searchSteps, 0, 32 ) )}\\\"\" );\n\t\t// See-through bakes start from a typical fit; the bake check fits both to the model after baking.\n\t\tsb.AppendLine( $\"\\tg_flFlatpackSoftCoverage \\\"{F( seeThrough ? 1.5f : 0.0f )}\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_flFlatpackSoftGrowth \\\"{F( seeThrough ? 0.5f : 0.0f )}\\\"\" );\n\t\tsb.AppendLine( $\"\\tg_flFlatpackBakedMetal \\\"{F( bakedMetal ? 1.0f : 0.0f )}\\\"\" );\n\t\tsb.AppendLine( \"}\" );\n\t\treturn sb.ToString();\n\t}\n\n\t/// <summary>\n\t/// Where the outputs go, and the Assets folder that asset paths are relative to.\n\t/// </summary>\n\tstatic (string Folder, string AssetsRoot) ResolveOutputFolder( Model model, FlatpackBakeSettings settings )\n\t{\n\t\tvar projectAssets = Project.Current.GetAssetsPath();\n\n\t\tif ( !string.IsNullOrWhiteSpace( settings.OutputFolder ) )\n\t\t{\n\t\t\tvar sub = settings.OutputFolder.Trim().Trim( '/', '\\\\' );\n\t\t\treturn (Path.GetFullPath( Path.Combine( projectAssets, sub ) ), projectAssets);\n\t\t}\n\n\t\t// Next to the source, but only when the source is a file in this project (or one of its libraries).\n\t\t// Engine, cloud and cached models are read-only to us.\n\t\tvar source = AssetSystem.FindByPath( model.ResourcePath );\n\t\tvar projectRoot = Path.GetFullPath( Project.Current.GetRootPath() );\n\t\tvar absolute = source?.AbsolutePath;\n\n\t\tif ( !string.IsNullOrEmpty( absolute ) )\n\t\t{\n\t\t\tabsolute = Path.GetFullPath( absolute );\n\t\t\tvar inProject = absolute.StartsWith( projectRoot, StringComparison.OrdinalIgnoreCase )\n\t\t\t\t&& !absolute.Contains( $\"{Path.DirectorySeparatorChar}.sbox{Path.DirectorySeparatorChar}\", StringComparison.OrdinalIgnoreCase );\n\n\t\t\tvar assetsRoot = inProject ? FindAssetsRoot( absolute ) : null;\n\t\t\tif ( assetsRoot is not null )\n\t\t\t\treturn (Path.GetDirectoryName( absolute ), assetsRoot);\n\t\t}\n\n\t\treturn (Path.Combine( projectAssets, \"flatpack\" ), projectAssets);\n\t}\n\n\tstatic string FindAssetsRoot( string file )\n\t{\n\t\tvar dir = new DirectoryInfo( Path.GetDirectoryName( file ) );\n\t\twhile ( dir is not null )\n\t\t{\n\t\t\tif ( dir.Name.Equals( \"Assets\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\treturn dir.FullName;\n\n\t\t\tdir = dir.Parent;\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tstatic string Relative( string assetsRoot, string file )\n\t{\n\t\treturn Path.GetRelativePath( assetsRoot, file ).Replace( '\\\\', '/' ).ToLowerInvariant();\n\t}\n\n\tstatic string SafeName( Model model )\n\t{\n\t\tvar name = Path.GetFileNameWithoutExtension( model.ResourcePath ?? model.Name ?? \"model\" );\n\t\tvar invalid = Path.GetInvalidFileNameChars();\n\t\treturn new string( name.Select( c => invalid.Contains( c ) || c == ' ' ? '_' : char.ToLowerInvariant( c ) ).ToArray() );\n\t}\n}\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Editor/FlatpackBakeWindow.cs",
            "FileName": "FlatpackBakeWindow.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\nusing Editor;\r\nusing Sandbox;\r\n\r\nnamespace Flatpack.EditorTools;\r\n\r\n/// <summary>\r\n/// The models queued in the bake window. A class of its own so a control sheet can edit the list.\r\n/// </summary>\r\npublic sealed class FlatpackBakeQueue\r\n{\r\n\t/// <summary>\r\n\t/// Models to bake, one impostor each.\r\n\t/// </summary>\r\n\t[Property] public List<Model> Models { get; set; } = new();\r\n}\r\n\r\n/// <summary>\r\n/// Editor window for baking impostors: a model list, the bake settings, and a bake button with progress and cancel.\r\n/// Open it from the Editor menu (Flatpack, Impostor Baker), or from a model's right-click menu.\r\n/// </summary>\r\npublic sealed class FlatpackBakeWindow : Dialog\r\n{\r\n\tconst string SettingsCookie = \"Flatpack.BakeSettings\";\r\n\r\n\tstatic FlatpackBakeWindow _current;\r\n\r\n\treadonly FlatpackBakeQueue _queue = new();\r\n\treadonly FlatpackBakeSettings _settings;\r\n\treadonly Label _status;\r\n\treadonly ProgressBar _bar;\r\n\treadonly Button _bake;\r\n\treadonly ControlSheet _models;\r\n\tCancellationTokenSource _cancel;\r\n\r\n\t/// <summary>\r\n\t/// True while a bake is running in this window.\r\n\t/// </summary>\r\n\tpublic bool IsBaking => _cancel is not null;\r\n\r\n\t[Menu( \"Editor\", \"Flatpack/Impostor Baker\" )]\r\n\tpublic static void OpenFromMenu() => Open();\r\n\r\n\t/// <summary>\r\n\t/// Show the bake window (reusing an open one) and queue <paramref name=\"models\"/>.\r\n\t/// </summary>\r\n\tpublic static FlatpackBakeWindow Open( params Model[] models )\r\n\t{\r\n\t\tif ( _current is not { IsValid: true } )\r\n\t\t\t_current = new FlatpackBakeWindow();\r\n\r\n\t\t_current.Enqueue( models );\r\n\t\t_current.Show();\r\n\t\treturn _current;\r\n\t}\r\n\r\n\tFlatpackBakeWindow() : base( EditorWindow )\r\n\t{\r\n\t\t_settings = EditorCookie.Get( SettingsCookie, new FlatpackBakeSettings() ) ?? new FlatpackBakeSettings();\r\n\r\n\t\tWindow.Title = \"Flatpack Impostor Baker\";\r\n\t\tWindow.SetWindowIcon( \"view_in_ar\" );\r\n\t\tWindow.Size = new Vector2( 460, 640 );\r\n\t\tWindow.StateCookie = \"FlatpackBakeWindow\";\r\n\r\n\t\tLayout = Layout.Column();\r\n\t\tLayout.Margin = 12;\r\n\t\tLayout.Spacing = 8;\r\n\r\n\t\tvar title = Layout.Add( new Label( \"Flatpack by Anakin Studio\" ) );\r\n\t\ttitle.SetStyles( \"font-weight: bold;\" );\r\n\r\n\t\tLayout.Add( new Label( \"Bakes each model into an impostor: atlases, a material and a .fpack resource. Add the .fpack to a Flatpack Renderer.\" ) { WordWrap = true } );\r\n\r\n\t\t_models = new ControlSheet();\r\n\t\t_models.AddObject( _queue.GetSerialized() );\r\n\t\tLayout.Add( _models );\r\n\r\n\t\tvar settings = new ControlSheet();\r\n\t\tvar serializedSettings = _settings.GetSerialized();\r\n\t\tserializedSettings.OnPropertyChanged += _ => EditorCookie.Set( SettingsCookie, _settings );\r\n\t\tsettings.AddObject( serializedSettings );\r\n\t\tLayout.Add( settings );\r\n\r\n\t\tLayout.AddStretchCell();\r\n\r\n\t\t_status = Layout.Add( new Label( \"Add models, then bake.\" ) { WordWrap = true } );\r\n\t\t_bar = Layout.Add( new ProgressBar( this ) );\r\n\t\t_bar.FixedHeight = 8;\r\n\t\t_bar.Visible = false;\r\n\r\n\t\tvar footer = Layout.AddRow();\r\n\t\tfooter.Spacing = 8;\r\n\t\tfooter.Add( new Button( \"Add Selected\", \"playlist_add\" )\r\n\t\t{\r\n\t\t\tToolTip = \"Queue the models of the selected objects in the open scene.\",\r\n\t\t\tClicked = AddSceneSelection,\r\n\t\t} );\r\n\t\tfooter.Add( new Button( \"Clear\", \"clear_all\" ) { Clicked = () => { _queue.Models.Clear(); Rebuild(); } } );\r\n\t\tfooter.Add( new Button( \"Apply to Scene\", \"forest\" )\r\n\t\t{\r\n\t\t\tToolTip = \"Add a Flatpack Renderer to every object in the open scene whose model has a baked impostor.\",\r\n\t\t\tClicked = () => _status.Text = $\"Added Flatpack Renderer to {ApplyToScene()} object(s).\",\r\n\t\t} );\r\n\t\tfooter.AddStretchCell();\r\n\t\t_bake = footer.Add( new Button.Primary( \"Bake\", \"view_in_ar\" ) { Clicked = OnBakeClicked } );\r\n\r\n\t\tShow();\r\n\t}\r\n\r\n\tpublic override void OnDestroyed()\r\n\t{\r\n\t\t_cancel?.Cancel();\r\n\t\tif ( _current == this )\r\n\t\t\t_current = null;\r\n\r\n\t\tbase.OnDestroyed();\r\n\t}\r\n\r\n\tvoid Enqueue( IEnumerable<Model> models )\r\n\t{\r\n\t\tforeach ( var model in models ?? Enumerable.Empty<Model>() )\r\n\t\t{\r\n\t\t\tif ( model is not null && !model.IsError && !_queue.Models.Contains( model ) )\r\n\t\t\t\t_queue.Models.Add( model );\r\n\t\t}\r\n\r\n\t\tRebuild();\r\n\t}\r\n\r\n\tvoid Rebuild()\r\n\t{\r\n\t\t// The list control doesn't watch the list for changes made from code, so rebuild the sheet.\r\n\t\t_models.Clear( true );\r\n\t\t_models.AddObject( _queue.GetSerialized() );\r\n\t}\r\n\r\n\tvoid AddSceneSelection()\r\n\t{\r\n\t\tvar session = SceneEditorSession.Active;\r\n\t\tif ( session is null )\r\n\t\t\treturn;\r\n\r\n\t\tvar found = session.Selection.OfType<GameObject>()\r\n\t\t\t.SelectMany( go => go.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )\r\n\t\t\t.Select( r => r.Model )\r\n\t\t\t.Where( m => m is not null );\r\n\r\n\t\tEnqueue( found.ToArray() );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Give every <see cref=\"ModelRenderer\"/> in the open scene that has a baked impostor and no\r\n\t/// <see cref=\"FlatpackRenderer\"/> one, as a single undo step. Returns how many it added.\r\n\t/// </summary>\r\n\tpublic static int ApplyToScene()\r\n\t{\r\n\t\tvar session = SceneEditorSession.Active;\r\n\t\tif ( session is null )\r\n\t\t\treturn 0;\r\n\r\n\t\tvar targets = session.Scene.GetAllComponents<ModelRenderer>()\r\n\t\t\t.Where( r => r.Model is not null && !r.Components.Get<FlatpackRenderer>().IsValid() )\r\n\t\t\t.Select( r => (Renderer: r, Impostor: FlatpackRenderer.FindImpostorFor( r.Model )) )\r\n\t\t\t.Where( x => x.Impostor is not null )\r\n\t\t\t.ToArray();\r\n\r\n\t\tif ( targets.Length == 0 )\r\n\t\t\treturn 0;\r\n\r\n\t\tusing var scene = session.Scene.Push();\r\n\t\tusing ( session.UndoScope( \"Apply Flatpack to Scene\" ).WithComponentCreations().Push() )\r\n\t\t{\r\n\t\t\tforeach ( var (renderer, impostor) in targets )\r\n\t\t\t{\r\n\t\t\t\tvar flatpack = renderer.GameObject.Components.Create<FlatpackRenderer>();\r\n\t\t\t\tflatpack.Source = renderer;\r\n\t\t\t\tflatpack.Impostor = impostor;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn targets.Length;\r\n\t}\r\n\r\n\tasync void OnBakeClicked()\r\n\t{\r\n\t\tif ( IsBaking )\r\n\t\t{\r\n\t\t\t_cancel.Cancel();\r\n\t\t\t_bake.Enabled = false;\r\n\t\t\t_status.Text = \"Cancelling...\";\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tawait BakeQueueAsync();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Bake everything queued. Returns the impostors that baked, in queue order.\r\n\t/// </summary>\r\n\tpublic async Task<List<FlatpackImpostor>> BakeQueueAsync()\r\n\t{\r\n\t\tvar results = new List<FlatpackImpostor>();\r\n\t\tvar models = _queue.Models.Where( m => m is not null && !m.IsError ).Distinct().ToArray();\r\n\t\tif ( models.Length == 0 )\r\n\t\t{\r\n\t\t\t_status.Text = \"Nothing to bake. Add a model first.\";\r\n\t\t\treturn results;\r\n\t\t}\r\n\r\n\t\t_cancel = new CancellationTokenSource();\r\n\t\t_bake.Text = \"Cancel\";\r\n\t\t_bake.Icon = \"close\";\r\n\t\t_bar.Visible = true;\r\n\r\n\t\tusing var toast = Editor.Application.Editor.ProgressSection();\r\n\t\ttoast.Title = \"Baking impostors\";\r\n\t\ttoast.TotalCount = models.Length;\r\n\r\n\t\tvar failed = 0;\r\n\t\ttry\r\n\t\t{\r\n\t\t\tfor ( var i = 0; i < models.Length; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar model = models[i];\r\n\t\t\t\tvar index = i;\r\n\r\n\t\t\t\tvoid Report( float fraction, string text )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( !IsValid )\r\n\t\t\t\t\t\treturn;\r\n\r\n\t\t\t\t\t_bar.Fraction = (index + fraction) / models.Length;\r\n\t\t\t\t\t_status.Text = $\"{model.ResourceName} ({index + 1} of {models.Length}): {text}\";\r\n\t\t\t\t}\r\n\r\n\t\t\t\ttry\r\n\t\t\t\t{\r\n\t\t\t\t\ttoast.Current = i;\r\n\t\t\t\t\ttoast.Subtitle = model.ResourceName;\r\n\r\n\t\t\t\t\tvar result = await FlatpackBaker.BakeAsync( model, _settings, Report, _cancel.Token );\r\n\t\t\t\t\tresults.Add( result.Impostor );\r\n\t\t\t\t\tLog.Info( $\"Flatpack: baked {model.ResourcePath} to {result.ImpostorPath} in {result.Duration.TotalSeconds:0.0} s\" );\r\n\t\t\t\t}\r\n\t\t\t\tcatch ( OperationCanceledException )\r\n\t\t\t\t{\r\n\t\t\t\t\tthrow;\r\n\t\t\t\t}\r\n\t\t\t\tcatch ( Exception e )\r\n\t\t\t\t{\r\n\t\t\t\t\tfailed++;\r\n\t\t\t\t\tLog.Warning( $\"Flatpack: couldn't bake {model.ResourcePath}: {e.Message}\" );\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif ( toast.GetCancel().IsCancellationRequested )\r\n\t\t\t\t\t_cancel.Cancel();\r\n\t\t\t}\r\n\r\n\t\t\t_status.Text = failed == 0\r\n\t\t\t\t? $\"Baked {results.Count} impostor{(results.Count == 1 ? \"\" : \"s\")}.\"\r\n\t\t\t\t: $\"Baked {results.Count}, {failed} failed. See the console.\";\r\n\t\t}\r\n\t\tcatch ( OperationCanceledException )\r\n\t\t{\r\n\t\t\t_status.Text = $\"Cancelled after {results.Count} of {models.Length}.\";\r\n\t\t}\r\n\t\tfinally\r\n\t\t{\r\n\t\t\t_cancel = null;\r\n\r\n\t\t\tif ( IsValid )\r\n\t\t\t{\r\n\t\t\t\t_bake.Text = \"Bake\";\r\n\t\t\t\t_bake.Icon = \"view_in_ar\";\r\n\t\t\t\t_bake.Enabled = true;\r\n\t\t\t\t_bar.Visible = false;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn results;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// A thin progress bar.\r\n\t/// </summary>\r\n\tsealed class ProgressBar : Widget\r\n\t{\r\n\t\tfloat _fraction;\r\n\r\n\t\tpublic float Fraction\r\n\t\t{\r\n\t\t\tget => _fraction;\r\n\t\t\tset\r\n\t\t\t{\r\n\t\t\t\t_fraction = Math.Clamp( value, 0.0f, 1.0f );\r\n\t\t\t\tUpdate();\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tpublic ProgressBar( Widget parent ) : base( parent ) { }\r\n\r\n\t\tprotected override void OnPaint()\r\n\t\t{\r\n\t\t\tPaint.SetPen( Theme.ControlBackground, 1.0f );\r\n\t\t\tPaint.SetBrush( Theme.WidgetBackground.Darken( 0.1f ) );\r\n\t\t\tPaint.DrawRect( LocalRect, 2.0f );\r\n\r\n\t\t\tif ( _fraction <= 0.0f )\r\n\t\t\t\treturn;\r\n\r\n\t\t\tvar filled = LocalRect.Shrink( 1 );\r\n\t\t\tfilled.Width *= _fraction;\r\n\r\n\t\t\tPaint.ClearPen();\r\n\t\t\tPaint.SetBrush( Theme.Primary );\r\n\t\t\tPaint.DrawRect( filled, 2.0f );\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Editor/FlatpackQualityCheck.cs",
            "FileName": "FlatpackQualityCheck.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing Sandbox;\n\nnamespace Flatpack.EditorTools;\n\n/// <summary>\n/// Measures how closely a bake matches its model: renders both in a private scene from a few angles at a range of\n/// distances and counts the pixels that differ clearly. The same test as the iteration 3 to 5 benchmarks, cut down to\n/// run in a second or two after every bake.\n/// </summary>\n/// <remarks>\n/// Both are rendered through the Albedo debug view, so lighting can't hide or invent differences: what's left is shape\n/// (missing thin parts, ghosting, holes) and colour, the failures that matter most for a card. Renders are made at the\n/// pixel density of a 1080p screen at 60 degrees FOV, at twice the resolution and averaged down, like the benchmarks.\n/// </remarks>\npublic static class FlatpackQualityCheck\n{\n\t/// <summary>A pixel counts as clearly different past this much average channel difference (of 255).</summary>\n\tpublic const int BadPixelThreshold = 25;\n\n\t/// <summary>\n\t/// A bake holds up at a distance when its structural difference from the model (DSSIM, 0..1) is at most this much\n\t/// above its own floor: the difference that's left far away, where nothing more can be gained (lighting, texture\n\t/// noise). Calibrated by eye in iteration 5: the chair looked right from 6 radii, the tree from 6 to 8, the knight\n\t/// from 8. 0.10 reproduces all three; a fixed cut-off on any single score, or a ratio to the floor, didn't.\n\t/// </summary>\n\tpublic const float HoldsUpExcess = 0.10f;\n\n\t/// <summary>\n\t/// Whatever the structure score says, more than this share of clearly wrong pixels (a wire mesh filled in solid, a\n\t/// missing part) doesn't hold up.\n\t/// </summary>\n\tpublic const float HoldsUpBadShare = 0.25f;\n\n\t/// <summary>Distances checked, in bounding radii, nearest first.</summary>\n\tpublic static readonly float[] Distances = { 1.5f, 2f, 3f, 4f, 6f, 8f, 12f, 16f, 24f, 30f };\n\n\t/// <summary>Viewing directions averaged at each distance: four sides, a little above.</summary>\n\tstatic readonly Angles[] Views = { new( 12, 35, 0 ), new( 12, 125, 0 ), new( 12, 215, 0 ), new( 12, 305, 0 ) };\n\n\tpublic sealed class Report\n\t{\n\t\t/// <summary>Share of the object's pixels that differ clearly from the model, per entry of <see cref=\"Distances\"/>.</summary>\n\t\tpublic float[] BadShare { get; init; }\n\n\t\t/// <summary>\n\t\t/// Structural difference from the model (1 - SSIM on luminance, 0..1) per entry of <see cref=\"Distances\"/>. Unlike\n\t\t/// <see cref=\"BadShare\"/> this sees blur and smearing, which is what makes a card look wrong up close.\n\t\t/// </summary>\n\t\tpublic float[] Difference { get; init; }\n\n\t\t/// <summary>The card's difference far away (median of 12 to 30 radii): the floor <see cref=\"HoldsUpFrom\"/> is judged against.</summary>\n\t\tpublic float Floor { get; init; }\n\n\t\t/// <summary>\n\t\t/// Nearest distance (bounding radii) from which every checked distance out to 16 radii holds up (see\n\t\t/// <see cref=\"HoldsUpExcess\"/> and <see cref=\"HoldsUpBadShare\"/>), or <see cref=\"float.PositiveInfinity\"/> when it\n\t\t/// never does.\n\t\t/// </summary>\n\t\tpublic float HoldsUpFrom { get; init; }\n\n\t\t/// <summary>\n\t\t/// The worst share of clearly different pixels from <paramref name=\"radii\"/> outward (to the furthest checked\n\t\t/// distance): what a card swapped in at that distance shows at its worst.\n\t\t/// </summary>\n\t\tpublic float WorstFrom( float radii )\n\t\t{\n\t\t\tvar worst = BadShareAt( radii );\n\t\t\tfor ( var i = 0; i < Distances.Length; i++ )\n\t\t\t{\n\t\t\t\tif ( Distances[i] >= radii && !float.IsNaN( BadShare[i] ) )\n\t\t\t\t\tworst = MathF.Max( worst, BadShare[i] );\n\t\t\t}\n\t\t\treturn worst;\n\t\t}\n\n\t\t/// <summary>One line for status bars and the inspector.</summary>\n\t\t/// <summary>The bake's bounding radius in world units, for showing distances in metres.</summary>\n\t\tpublic float Radius { get; init; }\n\n\t\tpublic string Summary => float.IsPositiveInfinity( HoldsUpFrom )\n\t\t\t? $\"Doesn't match the model closely inside 16 radii ({FlatpackMath.FormatMetres( 16.0f * Radius )}): keep the swap distance generous.\"\n\t\t\t: $\"Looks like the model from {HoldsUpFrom:0.#} bounding radii ({FlatpackMath.FormatMetres( HoldsUpFrom * Radius )}) outward.\";\n\n\t\t/// <summary>Share of clearly different pixels at <paramref name=\"radii\"/>, interpolated between checked distances.</summary>\n\t\tpublic float BadShareAt( float radii )\n\t\t{\n\t\t\tif ( radii <= Distances[0] ) return BadShare[0];\n\t\t\tfor ( var i = 1; i < Distances.Length; i++ )\n\t\t\t{\n\t\t\t\tif ( radii <= Distances[i] )\n\t\t\t\t{\n\t\t\t\t\tvar t = (radii - Distances[i - 1]) / (Distances[i] - Distances[i - 1]);\n\t\t\t\t\treturn BadShare[i - 1] + (BadShare[i] - BadShare[i - 1]) * t;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn BadShare[^1];\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// For see-through bakes: find the Soft Coverage strength and growth that make the card cover as much of the screen as\n\t/// the real model does at 8, 16 and 32 bounding radii (where cards show), from two sides, at the pixel density of a\n\t/// 1080p game view. Both go through the normal lit path (the debug views change how alpha-tested meshes resolve) onto\n\t/// a white background in an unlit scene, so a covered pixel is a dark one. Leaves the best values set on the\n\t/// impostor's material and returns them.\n\t/// </summary>\n\tpublic static async Task<(float Soft, float Growth)> FitSoftCoverageAsync( FlatpackImpostor impostor, CancellationToken cancel = default )\n\t{\n\t\tvar strengths = new[] { 1.0f, 1.25f, 1.5f, 1.75f, 2.0f, 2.5f, 3.0f, 4.0f };\n\t\tvar growths = new[] { 0.0f, 0.25f, 0.5f, 0.75f, 1.0f };\n\t\tvar distances = new[] { 8f, 16f, 32f };\n\t\tvar views = new[] { Views[0], Views[2] };\n\t\tvar material = impostor.Material;\n\n\t\tvar scene = Scene.CreateEditorScene();\n\t\tscene.Name = \"Flatpack Coverage Fit\";\n\t\ttry\n\t\t{\n\t\t\tModelRenderer model, card;\n\t\t\tCameraComponent camera;\n\t\t\tusing ( scene.Push() )\n\t\t\t{\n\t\t\t\tmodel = new GameObject( true, \"model\" ).Components.Create<ModelRenderer>();\n\t\t\t\tmodel.Model = impostor.SourceModel;\n\t\t\t\tmodel.LodOverride = 0;\n\t\t\t\tcard = new GameObject( true, \"card\" ).Components.Create<ModelRenderer>();\n\t\t\t\tcard.Model = impostor.CardModel;\n\t\t\t\tcamera = new GameObject( true, \"camera\" ).Components.Create<CameraComponent>();\n\t\t\t\tcamera.BackgroundColor = Color.White;\n\t\t\t}\n\n\t\t\tawait WarmUpAsync( scene, camera, card, impostor );\n\n\t\t\t// Each shot: framing for one distance and side, and the model's covered pixel count there.\n\t\t\tvar shots = new List<(Vector3 Position, Rotation Rotation, float Fov, Bitmap Target, long Model)>();\n\t\t\tforeach ( var k in distances )\n\t\t\t{\n\t\t\t\tvar halfAngle = MathF.Atan( 1.15f / k );\n\t\t\t\t// 1920 pixels across a 70 degree view: about 1371 pixels per unit of tangent.\n\t\t\t\tvar size = Math.Clamp( (int)(1371.0f * 2.0f * MathF.Tan( halfAngle )), 24, 1024 );\n\t\t\t\tforeach ( var view in views )\n\t\t\t\t{\n\t\t\t\t\tvar dir = view.ToRotation().Backward;\n\t\t\t\t\tvar shot = ( impostor.Center + dir * impostor.Radius * k, Rotation.LookAt( -dir, Vector3.Up ), halfAngle * 2.0f * (180.0f / MathF.PI), new Bitmap( size, size, false ), 0L );\n\t\t\t\t\tshot.Item5 = Covered( await Render( scene, camera, model, card, impostor, shot.Item1, shot.Item2, shot.Item3, shot.Item4, false ) );\n\t\t\t\t\tshots.Add( shot );\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvar best = (Soft: 1.0f, Growth: 1.0f, Error: double.MaxValue);\n\t\t\tforeach ( var soft in strengths )\n\t\t\t{\n\t\t\t\tforeach ( var growth in growths )\n\t\t\t\t{\n\t\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\t\tmaterial.Set( \"g_flFlatpackSoftCoverage\", soft );\n\t\t\t\t\tmaterial.Set( \"g_flFlatpackSoftGrowth\", growth );\n\t\t\t\t\tdouble error = 0;\n\t\t\t\t\tforeach ( var shot in shots )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar c = Covered( await Render( scene, camera, model, card, impostor, shot.Position, shot.Rotation, shot.Fov, shot.Target, true ) );\n\t\t\t\t\t\tvar rel = (c - shot.Model) / (double)Math.Max( shot.Model, 1 );\n\t\t\t\t\t\terror += rel * rel;\n\t\t\t\t\t}\n\t\t\t\t\tif ( error < best.Error )\n\t\t\t\t\t\tbest = (soft, growth, error);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tmaterial.Set( \"g_flFlatpackSoftCoverage\", best.Soft );\n\t\t\tmaterial.Set( \"g_flFlatpackSoftGrowth\", best.Growth );\n\t\t\treturn (best.Soft, best.Growth);\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tscene.Destroy();\n\t\t}\n\t}\n\n\tstatic async Task<Color32[]> Render( Scene scene, CameraComponent camera, ModelRenderer model, ModelRenderer card, FlatpackImpostor impostor,\n\t\tVector3 position, Rotation rotation, float fov, Bitmap target, bool showCard )\n\t{\n\t\tusing ( scene.Push() )\n\t\t{\n\t\t\tcamera.FieldOfView = fov;\n\t\t\tcamera.WorldPosition = position;\n\t\t\tcamera.WorldRotation = rotation;\n\t\t\tvar d = (position - impostor.Center).Length;\n\t\t\tcamera.ZNear = MathF.Max( 0.1f, (d - impostor.Radius * 1.2f) * 0.5f );\n\t\t\tcamera.ZFar = d + impostor.Radius * 2.0f;\n\t\t\timpostor.MarkAtlasesUsed();\n\t\t\tmodel.Enabled = !showCard;\n\t\t\tcard.Enabled = showCard;\n\t\t\tscene.EditorTick( 0.0f, 0.0f );\n\t\t\tcamera.RenderToBitmap( target );\n\t\t}\n\t\tawait Task.Delay( 1 );\n\t\treturn target.GetPixels32();\n\t}\n\n\t/// <summary>\n\t/// Render the card a few times, a frame apart, before anything is measured. Right after a bake into a new folder the\n\t/// new textures aren't on the GPU yet and the first renders read blank ones: in iteration 8 a chair measured from 12\n\t/// radii straight after its first bake, and from 4 once its textures had loaded.\n\t/// </summary>\n\tstatic async Task WarmUpAsync( Scene scene, CameraComponent camera, ModelRenderer card, FlatpackImpostor impostor )\n\t{\n\t\tvar target = new Bitmap( 64, 64, false );\n\t\tvar dir = new Vector3( 1.0f, 0.0f, 0.3f ).Normal;\n\t\tfor ( var i = 0; i < 20; i++ )\n\t\t{\n\t\t\tusing ( scene.Push() )\n\t\t\t{\n\t\t\t\tcamera.FieldOfView = 40.0f;\n\t\t\t\tcamera.WorldPosition = impostor.Center + dir * impostor.Radius * 4.0f;\n\t\t\t\tcamera.WorldRotation = Rotation.LookAt( -dir, Vector3.Up );\n\t\t\t\timpostor.MarkAtlasesUsed();\n\t\t\t\tcard.Enabled = true;\n\t\t\t\tscene.EditorTick( 0.0f, 0.0f );\n\t\t\t\tcamera.RenderToBitmap( target );\n\t\t\t}\n\t\t\tawait Task.Delay( 30 );\n\t\t}\n\t}\n\n\t/// <summary>Pixels clearly darker than the white background.</summary>\n\tstatic long Covered( Color32[] pixels )\n\t{\n\t\tlong n = 0;\n\t\tforeach ( var p in pixels )\n\t\t\tif ( p.r + p.g + p.b < 600 ) n++;\n\t\treturn n;\n\t}\n\n\t/// <summary>\n\t/// Compare <paramref name=\"impostor\"/> against its source model. <paramref name=\"upTo\"/> stops early, for a quick check\n\t/// at one distance (it still checks the distances below it).\n\t/// </summary>\n\tpublic static async Task<Report> MeasureAsync( FlatpackImpostor impostor, float upTo = float.MaxValue, CancellationToken cancel = default )\n\t{\n\t\tvar scene = Scene.CreateEditorScene();\n\t\tscene.Name = \"Flatpack Quality Check\";\n\t\tvar bad = new List<float>();\n\t\tvar structure = new List<float>();\n\n\t\ttry\n\t\t{\n\t\t\tModelRenderer model, card;\n\t\t\tCameraComponent camera;\n\n\t\t\tusing ( scene.Push() )\n\t\t\t{\n\t\t\t\tvar a = new GameObject( true, \"model\" );\n\t\t\t\tmodel = a.Components.Create<ModelRenderer>();\n\t\t\t\tmodel.Model = impostor.SourceModel;\n\t\t\t\tmodel.LodOverride = 0;\n\n\t\t\t\tvar b = new GameObject( true, \"card\" );\n\t\t\t\tcard = b.Components.Create<ModelRenderer>();\n\t\t\t\tcard.Model = impostor.CardModel;\n\n\t\t\t\tvar eye = new GameObject( true, \"camera\" );\n\t\t\t\tcamera = eye.Components.Create<CameraComponent>();\n\t\t\t\tcamera.BackgroundColor = Color.Transparent;\n\t\t\t\tcamera.DebugMode = SceneCameraDebugMode.Albedo;\n\t\t\t}\n\n\t\t\tawait WarmUpAsync( scene, camera, card, impostor );\n\n\t\t\tvar r = impostor.Radius;\n\t\t\tforeach ( var k in Distances )\n\t\t\t{\n\t\t\t\tif ( k > upTo && bad.Count > 0 )\n\t\t\t\t\tbreak;\n\n\t\t\t\t// Frame the bounding sphere with a little margin, at the pixel density a 1080p, 60 degree view would give.\n\t\t\t\tvar halfAngle = MathF.Atan( 1.15f / k );\n\t\t\t\tvar size = Math.Clamp( (int)(1080.0f * MathF.Tan( halfAngle ) / MathF.Tan( MathF.PI / 6.0f )), 24, 1024 );\n\t\t\t\tvar target = new Bitmap( size * 2, size * 2, false );\n\n\t\t\t\tlong badPixels = 0, objectPixels = 0;\n\t\t\t\tdouble dssimSum = 0;\n\t\t\t\tlong dssimCount = 0;\n\t\t\t\tforeach ( var view in Views )\n\t\t\t\t{\n\t\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\t\tvar dir = view.ToRotation().Backward;\n\n\t\t\t\t\tColor32[] m, c;\n\t\t\t\t\tusing ( scene.Push() )\n\t\t\t\t\t{\n\t\t\t\t\t\tcamera.FieldOfView = halfAngle * 2.0f * (180.0f / MathF.PI);\n\t\t\t\t\t\tcamera.WorldPosition = impostor.Center + dir * r * k;\n\t\t\t\t\t\tcamera.WorldRotation = Rotation.LookAt( -dir, Vector3.Up );\n\t\t\t\t\t\tcamera.ZNear = MathF.Max( 0.1f, r * (k - 1.2f) * 0.5f );\n\t\t\t\t\t\tcamera.ZFar = r * (k + 2.0f);\n\n\t\t\t\t\t\timpostor.MarkAtlasesUsed();\n\t\t\t\t\t\tmodel.Enabled = true; card.Enabled = false;\n\t\t\t\t\t\tscene.EditorTick( 0.0f, 0.0f );\n\t\t\t\t\t\tcamera.RenderToBitmap( target );\n\t\t\t\t\t\tm = target.GetPixels32();\n\n\t\t\t\t\t\tmodel.Enabled = false; card.Enabled = true;\n\t\t\t\t\t\tscene.EditorTick( 0.0f, 0.0f );\n\t\t\t\t\t\tcamera.RenderToBitmap( target );\n\t\t\t\t\t\tc = target.GetPixels32();\n\t\t\t\t\t}\n\n\t\t\t\t\tCompare( m, c, size * 2, ref badPixels, ref objectPixels );\n\t\t\t\t\tStructure( m, c, size * 2, ref dssimSum, ref dssimCount );\n\t\t\t\t\tawait Task.Delay( 1 );\n\t\t\t\t}\n\n\t\t\t\tbad.Add( objectPixels > 0 ? badPixels / (float)objectPixels : 0.0f );\n\t\t\t\tstructure.Add( dssimCount > 0 ? (float)(dssimSum / dssimCount) : 0.0f );\n\t\t\t}\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tscene.Destroy();\n\t\t}\n\n\t\twhile ( bad.Count < Distances.Length )\n\t\t{\n\t\t\tbad.Add( float.NaN );\n\t\t\tstructure.Add( float.NaN );\n\t\t}\n\n\t\tvar far = new List<float>();\n\t\tfor ( var i = 0; i < Distances.Length; i++ )\n\t\t\tif ( Distances[i] >= 12.0f && !float.IsNaN( structure[i] ) )\n\t\t\t\tfar.Add( structure[i] );\n\t\tfar.Sort();\n\t\tvar floor = far.Count > 0 ? far[far.Count / 2] : float.NaN;\n\n\t\tvar holds = float.PositiveInfinity;\n\t\tfor ( var i = Distances.Length - 1; i >= 0; i-- )\n\t\t{\n\t\t\tif ( Distances[i] > 16.0f ) continue;\n\t\t\tif ( float.IsNaN( bad[i] ) || float.IsNaN( floor ) || bad[i] > HoldsUpBadShare || structure[i] > floor + HoldsUpExcess ) break;\n\t\t\tholds = Distances[i];\n\t\t}\n\n\t\treturn new Report { BadShare = bad.ToArray(), Difference = structure.ToArray(), Floor = floor, HoldsUpFrom = holds, Radius = impostor.Radius };\n\t}\n\n\t/// <summary>\n\t/// Average 2 x 2 blocks of both renders (a stand-in for anti-aliasing), then count object pixels that differ clearly:\n\t/// <list type=\"bullet\">\n\t/// <item>Shape: coverage blurred over 3 x 3 pixels differs by more than a quarter. Blurring forgives a thin wire\n\t/// drawn one pixel over, but not a part that's missing or a see-through mesh that filled in solid.</item>\n\t/// <item>Colour: both cover the pixel and the colours differ by more than <see cref=\"BadPixelThreshold\"/>.</item>\n\t/// </list>\n\t/// A pixel belongs to the object when either render covers more than half of it.\n\t/// </summary>\n\tstatic void Compare( Color32[] m, Color32[] c, int size, ref long badPixels, ref long objectPixels )\n\t{\n\t\tvar half = size / 2;\n\t\tvar count = half * half;\n\t\tvar mCover = new float[count];\n\t\tvar cCover = new float[count];\n\t\tvar mRgb = new int[count * 3];\n\t\tvar cRgb = new int[count * 3];\n\n\t\tfor ( var y = 0; y < half; y++ )\n\t\t{\n\t\t\tfor ( var x = 0; x < half; x++ )\n\t\t\t{\n\t\t\t\tvar o = y * half + x;\n\t\t\t\tfor ( var dy = 0; dy < 2; dy++ )\n\t\t\t\t{\n\t\t\t\t\tfor ( var dx = 0; dx < 2; dx++ )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar i = (y * 2 + dy) * size + x * 2 + dx;\n\t\t\t\t\t\tvar pm = m[i]; var pc = c[i];\n\t\t\t\t\t\tmCover[o] += pm.a / (4.0f * 255.0f);\n\t\t\t\t\t\tcCover[o] += pc.a / (4.0f * 255.0f);\n\t\t\t\t\t\tmRgb[o * 3] += pm.r; mRgb[o * 3 + 1] += pm.g; mRgb[o * 3 + 2] += pm.b;\n\t\t\t\t\t\tcRgb[o * 3] += pc.r; cRgb[o * 3 + 1] += pc.g; cRgb[o * 3 + 2] += pc.b;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor ( var y = 0; y < half; y++ )\n\t\t{\n\t\t\tfor ( var x = 0; x < half; x++ )\n\t\t\t{\n\t\t\t\tvar o = y * half + x;\n\t\t\t\tif ( mCover[o] <= 0.5f && cCover[o] <= 0.5f )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tobjectPixels++;\n\n\t\t\t\tfloat mBlur = 0, cBlur = 0, n = 0;\n\t\t\t\tfor ( var dy = -1; dy <= 1; dy++ )\n\t\t\t\t{\n\t\t\t\t\tfor ( var dx = -1; dx <= 1; dx++ )\n\t\t\t\t\t{\n\t\t\t\t\t\tint yy = y + dy, xx = x + dx;\n\t\t\t\t\t\tif ( yy < 0 || yy >= half || xx < 0 || xx >= half ) continue;\n\t\t\t\t\t\tmBlur += mCover[yy * half + xx];\n\t\t\t\t\t\tcBlur += cCover[yy * half + xx];\n\t\t\t\t\t\tn++;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif ( MathF.Abs( mBlur - cBlur ) / n > 0.25f )\n\t\t\t\t{\n\t\t\t\t\tbadPixels++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif ( mCover[o] > 0.5f && cCover[o] > 0.5f )\n\t\t\t\t{\n\t\t\t\t\tvar diff = (Math.Abs( mRgb[o * 3] - cRgb[o * 3] ) + Math.Abs( mRgb[o * 3 + 1] - cRgb[o * 3 + 1] ) + Math.Abs( mRgb[o * 3 + 2] - cRgb[o * 3 + 2] )) / 12.0f;\n\t\t\t\t\tif ( diff > BadPixelThreshold )\n\t\t\t\t\t\tbadPixels++;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Structural difference (1 - SSIM, Wang et al. 2004) between the two renders' luminance, Gaussian window of 1.5\n\t/// pixels, at half resolution like <see cref=\"Compare\"/>. Summed over pixels near the object (either render covers\n\t/// something within a few pixels); uncovered pixels count as the benchmarks' mid grey.\n\t/// </summary>\n\tstatic void Structure( Color32[] m, Color32[] c, int size, ref double sum, ref long count )\n\t{\n\t\tvar half = size / 2;\n\t\tvar n = half * half;\n\t\tvar a = new float[n];\n\t\tvar b = new float[n];\n\t\tvar cover = new float[n];\n\n\t\tfor ( var y = 0; y < half; y++ )\n\t\t{\n\t\t\tfor ( var x = 0; x < half; x++ )\n\t\t\t{\n\t\t\t\tfloat la = 0, lb = 0, cv = 0;\n\t\t\t\tfor ( var dy = 0; dy < 2; dy++ )\n\t\t\t\t{\n\t\t\t\t\tfor ( var dx = 0; dx < 2; dx++ )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar i = (y * 2 + dy) * size + x * 2 + dx;\n\t\t\t\t\t\tla += Luma( m[i] );\n\t\t\t\t\t\tlb += Luma( c[i] );\n\t\t\t\t\t\tcv += Math.Max( m[i].a, c[i].a ) / 255.0f;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tvar o = y * half + x;\n\t\t\t\ta[o] = la * 0.25f;\n\t\t\t\tb[o] = lb * 0.25f;\n\t\t\t\tcover[o] = cv * 0.25f;\n\t\t\t}\n\t\t}\n\n\t\tvar muA = Blur( a, half );\n\t\tvar muB = Blur( b, half );\n\t\tvar aa = new float[n];\n\t\tvar bb = new float[n];\n\t\tvar ab = new float[n];\n\t\tfor ( var i = 0; i < n; i++ )\n\t\t{\n\t\t\taa[i] = a[i] * a[i];\n\t\t\tbb[i] = b[i] * b[i];\n\t\t\tab[i] = a[i] * b[i];\n\t\t}\n\n\t\tvar sAA = Blur( aa, half );\n\t\tvar sBB = Blur( bb, half );\n\t\tvar sAB = Blur( ab, half );\n\t\tvar near = Blur( cover, half );\n\n\t\tconst float c1 = (0.01f * 255.0f) * (0.01f * 255.0f);\n\t\tconst float c2 = (0.03f * 255.0f) * (0.03f * 255.0f);\n\t\tfor ( var i = 0; i < n; i++ )\n\t\t{\n\t\t\tif ( near[i] < 0.02f )\n\t\t\t\tcontinue;\n\n\t\t\tvar va = sAA[i] - muA[i] * muA[i];\n\t\t\tvar vb = sBB[i] - muB[i] * muB[i];\n\t\t\tvar cov = sAB[i] - muA[i] * muB[i];\n\t\t\tvar ssim = ((2 * muA[i] * muB[i] + c1) * (2 * cov + c2)) / ((muA[i] * muA[i] + muB[i] * muB[i] + c1) * (va + vb + c2));\n\t\t\tsum += 1.0 - ssim;\n\t\t\tcount++;\n\t\t}\n\t}\n\n\tstatic float Luma( Color32 p ) => p.a > 127 ? p.r * 0.2126f + p.g * 0.7152f + p.b * 0.0722f : 128.0f;\n\n\tstatic readonly float[] Kernel = MakeKernel( 1.5f, 4 );\n\n\tstatic float[] MakeKernel( float sigma, int radius )\n\t{\n\t\tvar k = new float[radius * 2 + 1];\n\t\tvar total = 0.0f;\n\t\tfor ( var i = -radius; i <= radius; i++ )\n\t\t{\n\t\t\tk[i + radius] = MathF.Exp( -i * i / (2 * sigma * sigma) );\n\t\t\ttotal += k[i + radius];\n\t\t}\n\n\t\tfor ( var i = 0; i < k.Length; i++ )\n\t\t\tk[i] /= total;\n\t\treturn k;\n\t}\n\n\t/// <summary>Separable Gaussian blur of a square image, clamped at the edges.</summary>\n\tstatic float[] Blur( float[] src, int w )\n\t{\n\t\tvar r = Kernel.Length / 2;\n\t\tvar tmp = new float[src.Length];\n\t\tvar dst = new float[src.Length];\n\t\tfor ( var y = 0; y < w; y++ )\n\t\t{\n\t\t\tfor ( var x = 0; x < w; x++ )\n\t\t\t{\n\t\t\t\tfloat v = 0;\n\t\t\t\tfor ( var k = -r; k <= r; k++ )\n\t\t\t\t\tv += src[y * w + Math.Clamp( x + k, 0, w - 1 )] * Kernel[k + r];\n\t\t\t\ttmp[y * w + x] = v;\n\t\t\t}\n\t\t}\n\n\t\tfor ( var y = 0; y < w; y++ )\n\t\t{\n\t\t\tfor ( var x = 0; x < w; x++ )\n\t\t\t{\n\t\t\t\tfloat v = 0;\n\t\t\t\tfor ( var k = -r; k <= r; k++ )\n\t\t\t\t\tv += tmp[Math.Clamp( y + k, 0, w - 1 ) * w + x] * Kernel[k + r];\n\t\t\t\tdst[y * w + x] = v;\n\t\t\t}\n\t\t}\n\n\t\treturn dst;\n\t}\n}\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Code/FlatpackRenderer.cs",
            "FileName": "FlatpackRenderer.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace Flatpack;\r\n\r\n/// <summary>\r\n/// One mesh LOD in a <see cref=\"FlatpackRenderer\"/> chain: a model and the camera distance it takes over from.\r\n/// </summary>\r\npublic sealed class FlatpackLodLevel\r\n{\r\n\t/// <summary>\r\n\t/// The model to show in this band. Usually a lower-poly version of the source.\r\n\t/// </summary>\r\n\t[Property] public Model Model { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// Camera distance where this level replaces the one before it, in world units.\r\n\t/// </summary>\r\n\t[Property, Range( 0, 20000 )] public float Distance { get; set; } = 1000.0f;\r\n}\r\n\r\n/// <summary>\r\n/// Shows a model up close and its Flatpack impostor card far away.\r\n/// </summary>\r\n/// <remarks>\r\n/// <para>\r\n/// Put this next to a <see cref=\"ModelRenderer\"/> and assign a baked <see cref=\"FlatpackImpostor\"/>. The renderer\r\n/// doesn't tick on its own: <see cref=\"FlatpackSystem\"/> checks distances for every renderer in one batched,\r\n/// time-sliced pass, so thousands of instances cost one loop instead of thousands of updates.\r\n/// </para>\r\n/// <para>\r\n/// All cards made from the same impostor share one model and material, so the engine can instance them.\r\n/// </para>\r\n/// <para>\r\n/// LODs: the source <c>.vmdl</c>'s own LODs keep working up close, because this component never touches the\r\n/// <see cref=\"ModelRenderer\"/> until the swap. For separate model files per detail level, fill <see cref=\"Levels\"/>:\r\n/// the renderer swaps the source's model by distance (with the same hysteresis), then hands over to the card past\r\n/// the swap distance. With no <see cref=\"ModelRenderer\"/> at all the card shows at every distance.\r\n/// </para>\r\n/// <para>\r\n/// Alternative without this component: make the card the last LOD of the <c>.vmdl</c> (see <see cref=\"FlatpackImpostor\"/>).\r\n/// That gives you engine LOD switching for free, but no hysteresis and no cross-fade.\r\n/// </para>\r\n/// </remarks>\r\n[Title( \"Flatpack Renderer\" ), Category( \"Rendering\" ), Icon( \"view_in_ar\" )]\r\npublic sealed class FlatpackRenderer : Component, Component.ExecuteInEditor\r\n{\r\n\t/// <summary>\r\n\t/// What to draw.\r\n\t/// </summary>\r\n\tpublic enum DisplayMode\r\n\t{\r\n\t\t/// <summary>Model inside <see cref=\"EffectiveSwapDistance\"/>, card beyond it.</summary>\r\n\t\tAuto,\r\n\t\t/// <summary>Always the real model.</summary>\r\n\t\tForceModel,\r\n\t\t/// <summary>Always the card. Useful for checking a bake.</summary>\r\n\t\tForceCard,\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The baked impostor to show at distance. Left empty, the renderer uses any baked impostor whose source model\r\n\t/// matches the <see cref=\"Source\"/> renderer's model, so adding the component after a bake needs no setup.\r\n\t/// </summary>\r\n\t[Property] public FlatpackImpostor Impostor { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// The renderer for the real model. Found on this GameObject if left empty.\r\n\t/// </summary>\r\n\t[Property] public ModelRenderer Source { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// Pick the swap distance from the object's size: the card takes over once the bounding sphere is about\r\n\t/// 1/<see cref=\"AutoDistanceFactor\"/> of the distance across, roughly 5% of a 1080p screen's height at 70 degrees FOV.\r\n\t/// </summary>\r\n\t[Property] public bool AutoSwapDistance { get; set; } = true;\r\n\r\n\t/// <summary>\r\n\t/// Auto mode swaps as soon as the card looks like the model: at the distance the baker measured\r\n\t/// (<see cref=\"FlatpackImpostor.HoldsUpFrom\"/>, calibrated by eye), never closer than <see cref=\"MinimumSwapRadii\"/>.\r\n\t/// Measured in iteration 6: a 534,000-triangle tree forest drew in 7 to 13 ms swapping at 8 radii against 25 to 28 ms\r\n\t/// at 30. Turn it off to swap at <see cref=\"AutoDistanceFactor\"/> instead (bakes that weren't measured always do).\r\n\t/// </summary>\r\n\t[Property, ShowIf( nameof( AutoSwapDistance ), true )] public bool SwapWhenItLooksRight { get; set; } = true;\r\n\r\n\t/// <summary>\r\n\t/// The closest an early swap may happen, in bounding radii. Up close a card covers many pixels and costs more to\r\n\t/// draw, so very light models can end up slower as cards than as models.\r\n\t/// </summary>\r\n\t[Property, Range( 2, 30 ), ShowIf( nameof( SwapWhenItLooksRight ), true )] public float MinimumSwapRadii { get; set; } = 6.0f;\r\n\r\n\t/// <summary>\r\n\t/// Auto mode swaps at this many bounding-sphere radii from the camera when it isn't swapping early (or the bake\r\n\t/// wasn't measured), or where the bake holds up if that's further.\r\n\t/// </summary>\r\n\t[Property, Range( 5, 100 ), ShowIf( nameof( AutoSwapDistance ), true )] public float AutoDistanceFactor { get; set; } = 30.0f;\r\n\r\n\t/// <summary>\r\n\t/// Camera distance where the card takes over, in world units, when <see cref=\"AutoSwapDistance\"/> is off.\r\n\t/// </summary>\r\n\t[Property, Range( 0, 20000 ), ShowIf( nameof( AutoSwapDistance ), false )] public float SwapDistance { get; set; } = 2000.0f;\r\n\r\n\t/// <summary>\r\n\t/// The swap distance in use: <see cref=\"SwapDistance\"/>, or the automatic one, then scaled by the global\r\n\t/// <see cref=\"FlatpackSystem.Level\"/> (a game's graphics setting).\r\n\t/// </summary>\r\n\tpublic float EffectiveSwapDistance\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar (levelScale, levelMinimum) = FlatpackSystem.LevelScaling;\r\n\t\t\tif ( float.IsPositiveInfinity( levelScale ) )\r\n\t\t\t\treturn float.PositiveInfinity;\r\n\r\n\t\t\tif ( !AutoSwapDistance || Impostor is null )\r\n\t\t\t\treturn SwapDistance * levelScale;\r\n\r\n\t\t\tvar scale = WorldScale;\r\n\t\t\tvar radius = Impostor.Radius * MathF.Max( scale.x, MathF.Max( scale.y, scale.z ) );\r\n\t\t\tvar measured = Impostor.HoldsUpFrom > 0.0f;\r\n\t\t\tvar factor = SwapWhenItLooksRight && measured\r\n\t\t\t\t? MathF.Max( Impostor.HoldsUpFrom, MinimumSwapRadii )\r\n\t\t\t\t: MathF.Max( AutoDistanceFactor, Impostor.HoldsUpFrom );\r\n\r\n\t\t\t// A lower graphics level swaps earlier, but never closer than its own minimum.\r\n\t\t\tfactor *= levelScale;\r\n\t\t\tif ( levelMinimum > 0.0f )\r\n\t\t\t\tfactor = MathF.Max( factor, levelMinimum );\r\n\t\t\treturn radius * factor;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Width of the dead band around <see cref=\"SwapDistance\"/>. The card appears past SwapDistance + Hysteresis / 2\r\n\t/// and the model returns inside SwapDistance - Hysteresis / 2, so a camera hovering at the boundary doesn't flicker.\r\n\t/// </summary>\r\n\t[Property, Range( 0, 2000 )] public float Hysteresis { get; set; } = 200.0f;\r\n\r\n\t/// <summary>\r\n\t/// Dither the card in and out over <see cref=\"FadeTime\"/> instead of popping. The model stays visible under the\r\n\t/// card until the fade-in finishes, so the swap reads as a dissolve.\r\n\t/// </summary>\r\n\t[Property] public bool CrossFade { get; set; } = true;\r\n\r\n\t/// <summary>\r\n\t/// Seconds a cross-fade takes.\r\n\t/// </summary>\r\n\t[Property, Range( 0, 2 ), ShowIf( nameof( CrossFade ), true )] public float FadeTime { get; set; } = 0.35f;\r\n\r\n\t/// <summary>\r\n\t/// Override the distance check.\r\n\t/// </summary>\r\n\t[Property] public DisplayMode Mode { get; set; } = DisplayMode.Auto;\r\n\r\n\t/// <summary>\r\n\t/// Optional mesh LODs between the source model and the card, nearest first. Each replaces the source renderer's\r\n\t/// model past its <see cref=\"FlatpackLodLevel.Distance\"/>. Distances past <see cref=\"SwapDistance\"/> never show.\r\n\t/// </summary>\r\n\t[Property] public List<FlatpackLodLevel> Levels { get; set; } = new();\r\n\r\n\t/// <summary>\r\n\t/// Index into <see cref=\"Levels\"/> currently on the source renderer, or -1 for the source's own model.\r\n\t/// </summary>\r\n\tpublic int CurrentLevel { get; private set; } = -1;\r\n\r\n\t/// <summary>\r\n\t/// True while the card is the visible representation (including while it fades in).\r\n\t/// </summary>\r\n\tpublic bool ShowingCard { get; private set; }\r\n\r\n\tSceneObject _card;\r\n\tModel _baseModel;      // the source renderer's own model, restored when the chain steps back to level -1\r\n\tFlatpackImpostor _countedImpostor;   // impostor this renderer's visible card is counted against in the system\r\n\tfloat _fade;           // 0 = card hidden, 1 = card fully shown\r\n\tfloat _fadeTarget;\r\n\tinternal int SystemIndex = -1;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tSource ??= GameObject.Components.Get<ModelRenderer>();\r\n\t\t_baseModel = Source.IsValid() ? Source.Model : null;\r\n\t\tImpostor ??= FindImpostorFor( _baseModel );\r\n\t\tCurrentLevel = -1;\r\n\t\tTransform.OnTransformChanged += SyncTransform;\r\n\r\n\t\tScene.GetSystem<FlatpackSystem>()?.Register( this );\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tTransform.OnTransformChanged -= SyncTransform;\r\n\t\tScene.GetSystem<FlatpackSystem>()?.Unregister( this );\r\n\r\n\t\tDestroyCard();\r\n\t\tSetLevel( -1 );\r\n\t\tSetSourceVisible( true );\r\n\t\tShowingCard = false;\r\n\t\t_fade = _fadeTarget = 0.0f;\r\n\t}\r\n\r\n\tprotected override void OnValidate()\r\n\t{\r\n\t\t// An edited impostor or a changed mode should show up straight away in the editor.\r\n\t\tDestroyCard();\r\n\t\tSetLevel( -1 );\r\n\t\tShowingCard = false;\r\n\t\t_fade = _fadeTarget = 0.0f;\r\n\t\tSetSourceVisible( true );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Called by <see cref=\"FlatpackSystem\"/> with the squared distance from the camera to this object.\r\n\t/// Decides which representation should be showing; the fade itself runs in <see cref=\"TickFade\"/>.\r\n\t/// </summary>\r\n\tinternal bool Evaluate( float distanceSquared )\r\n\t{\r\n\t\tUpdateLevel( distanceSquared );\r\n\r\n\t\tvar wantCard = Mode switch\r\n\t\t{\r\n\t\t\tDisplayMode.ForceCard => true,\r\n\t\t\tDisplayMode.ForceModel => false,\r\n\t\t\t_ => WantCardAt( distanceSquared ),\r\n\t\t};\r\n\r\n\t\t// Card-only setup: nothing else to show.\r\n\t\tif ( !Source.IsValid() && Mode != DisplayMode.ForceModel )\r\n\t\t\twantCard = true;\r\n\r\n\t\tif ( wantCard && Impostor?.Material is null )\r\n\t\t\twantCard = false;\r\n\r\n\t\tif ( wantCard == ShowingCard )\r\n\t\t\treturn false;\r\n\r\n\t\tShowingCard = wantCard;\r\n\t\t_fadeTarget = wantCard ? 1.0f : 0.0f;\r\n\r\n\t\tif ( wantCard )\r\n\t\t\tEnsureCard();\r\n\r\n\t\tif ( !CrossFade || FadeTime <= 0.0f )\r\n\t\t\t_fade = _fadeTarget;\r\n\r\n\t\tApply();\r\n\r\n\t\t// The system keeps calling TickFade while this returns true.\r\n\t\treturn _fade != _fadeTarget;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Step the mesh LOD chain toward the level for this distance, one band edge at a time, each with the same\r\n\t/// hysteresis band as the card swap.\r\n\t/// </summary>\r\n\tvoid UpdateLevel( float distanceSquared )\r\n\t{\r\n\t\tif ( Levels is null || Levels.Count == 0 || !Source.IsValid() || Mode != DisplayMode.Auto )\r\n\t\t\treturn;\r\n\r\n\t\tvar level = Math.Min( CurrentLevel, Levels.Count - 1 );\r\n\t\tvar half = Hysteresis * 0.5f;\r\n\r\n\t\twhile ( level + 1 < Levels.Count && Beyond( distanceSquared, Levels[level + 1].Distance + half ) )\r\n\t\t\tlevel++;\r\n\r\n\t\twhile ( level >= 0 && !Beyond( distanceSquared, Levels[level].Distance - half ) )\r\n\t\t\tlevel--;\r\n\r\n\t\tSetLevel( level );\r\n\t}\r\n\r\n\tstatic bool Beyond( float distanceSquared, float distance )\r\n\t{\r\n\t\tdistance = MathF.Max( distance, 0.0f );\r\n\t\treturn distanceSquared > distance * distance;\r\n\t}\r\n\r\n\tvoid SetLevel( int level )\r\n\t{\r\n\t\tif ( level == CurrentLevel || !Source.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tCurrentLevel = level;\r\n\t\tvar model = level >= 0 ? Levels[level].Model : _baseModel;\r\n\t\tif ( model is not null )\r\n\t\t\tSource.Model = model;\r\n\r\n\t\t// A new model can mean a new scene object, which starts visible.\r\n\t\tSetSourceVisible( !ShowingCard || _fade < 1.0f );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The first baked impostor whose source is <paramref name=\"model\"/>, or null.\r\n\t/// </summary>\r\n\tpublic static FlatpackImpostor FindImpostorFor( Model model )\r\n\t{\r\n\t\tif ( model is null )\r\n\t\t\treturn null;\r\n\r\n\t\treturn ResourceLibrary.GetAll<FlatpackImpostor>()\r\n\t\t\t.FirstOrDefault( x => x.SourceModel is not null && x.SourceModel.ResourcePath == model.ResourcePath );\r\n\t}\r\n\r\n\tbool WantCardAt( float distanceSquared )\r\n\t{\r\n\t\t// Hysteresis: the threshold depends on which side we're already on.\r\n\t\tvar swap = EffectiveSwapDistance;\r\n\t\tvar threshold = ShowingCard\r\n\t\t\t? swap - Hysteresis * 0.5f\r\n\t\t\t: swap + Hysteresis * 0.5f;\r\n\r\n\t\tthreshold = MathF.Max( threshold, 0.0f );\r\n\t\treturn distanceSquared > threshold * threshold;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Advances a running cross-fade. Returns false when it has finished.\r\n\t/// </summary>\r\n\tinternal bool TickFade( float delta )\r\n\t{\r\n\t\tvar step = FadeTime > 0.0f ? delta / FadeTime : 1.0f;\r\n\t\t_fade = _fadeTarget > _fade\r\n\t\t\t? MathF.Min( _fade + step, _fadeTarget )\r\n\t\t\t: MathF.Max( _fade - step, _fadeTarget );\r\n\r\n\t\tApply();\r\n\t\treturn _fade != _fadeTarget;\r\n\t}\r\n\r\n\tvoid Apply()\r\n\t{\r\n\t\tif ( _card.IsValid() )\r\n\t\t{\r\n\t\t\t_card.RenderingEnabled = _fade > 0.0f;\r\n\t\t\tCountCard( _fade > 0.0f ? Impostor : null );\r\n\r\n\t\t\t// 1 draws every pixel; anything lower dithers the card out (see flatpack_impostor.shader).\r\n\t\t\t_card.Attributes.Set( \"FlatpackFade\", _fade );\r\n\t\t}\r\n\r\n\t\t// The model stays up until the card has fully faded in, and comes back the moment we head toward it.\r\n\t\tSetSourceVisible( !ShowingCard || _fade < 1.0f );\r\n\t}\r\n\r\n\tvoid SetSourceVisible( bool visible )\r\n\t{\r\n\t\tif ( Source.IsValid() && Source.SceneObject.IsValid() )\r\n\t\t\tSource.SceneObject.RenderingEnabled = visible && Source.Enabled;\r\n\t}\r\n\r\n\tvoid EnsureCard()\r\n\t{\r\n\t\tif ( _card.IsValid() || Impostor is null )\r\n\t\t\treturn;\r\n\r\n\t\t_card = new SceneObject( Scene.SceneWorld, Impostor.CardModel, WorldTransform );\r\n\t\t_card.Tags.SetFrom( GameObject.Tags );\r\n\t\t_card.RenderingEnabled = false;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Keep <see cref=\"FlatpackSystem\"/>'s count of visible cards per impostor current, so it streams their atlases in.\r\n\t/// </summary>\r\n\tvoid CountCard( FlatpackImpostor visibleImpostor )\r\n\t{\r\n\t\tif ( visibleImpostor == _countedImpostor )\r\n\t\t\treturn;\r\n\r\n\t\tvar system = Scene?.GetSystem<FlatpackSystem>();\r\n\t\tsystem?.CardVisibilityChanged( _countedImpostor, false );\r\n\t\tsystem?.CardVisibilityChanged( visibleImpostor, true );\r\n\t\t_countedImpostor = visibleImpostor;\r\n\t}\r\n\r\n\tvoid DestroyCard()\r\n\t{\r\n\t\tCountCard( null );\r\n\t\t_card?.Delete();\r\n\t\t_card = null;\r\n\t}\r\n\r\n\tvoid SyncTransform()\r\n\t{\r\n\t\tif ( _card.IsValid() )\r\n\t\t\t_card.Transform = WorldTransform;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "FlatpackCard.cs",
            "FileName": "FlatpackCard.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace Flatpack;\r\n\r\n/// <summary>\r\n/// Builds the card mesh an impostor is drawn with.\r\n/// </summary>\r\npublic static class FlatpackCard\r\n{\r\n\t/// <summary>\r\n\t/// One quad, two triangles, with the impostor material. The vertex shader moves the corners every frame to\r\n\t/// face the camera, using only the UVs; the positions here are a plausible fallback (a quad facing +X through the\r\n\t/// bounds centre) for anything that reads the mesh on the CPU.\r\n\t/// </summary>\r\n\tpublic static Model Build( FlatpackImpostor impostor )\r\n\t{\r\n\t\treturn Model.Builder\r\n\t\t\t.WithName( $\"flatpack_card_{impostor.ResourceName}\" )\r\n\t\t\t.AddMesh( BuildMesh( impostor ) )\r\n\t\t\t.Create();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The card quad as a mesh, for adding to another model (the card as the last LOD of a model).\r\n\t/// </summary>\r\n\tpublic static Mesh BuildMesh( FlatpackImpostor impostor )\r\n\t{\r\n\t\tvar c = impostor.Center;\r\n\t\tvar r = impostor.Radius;\r\n\r\n\t\tvar normal = Vector3.Forward;\r\n\t\tvar tangent = Vector3.Left;\r\n\r\n\t\t// UV (0,0) is the top-left corner; the shader maps u to the card's right and v to its down.\r\n\t\tvar vertices = new[]\r\n\t\t{\r\n\t\t\tnew Vertex( c + new Vector3( 0, -r, r ), normal, tangent, new Vector4( 0, 0, 0, 0 ) ),\r\n\t\t\tnew Vertex( c + new Vector3( 0, r, r ), normal, tangent, new Vector4( 1, 0, 0, 0 ) ),\r\n\t\t\tnew Vertex( c + new Vector3( 0, r, -r ), normal, tangent, new Vector4( 1, 1, 0, 0 ) ),\r\n\t\t\tnew Vertex( c + new Vector3( 0, -r, -r ), normal, tangent, new Vector4( 0, 1, 0, 0 ) ),\r\n\t\t};\r\n\r\n\t\tvar indices = new[] { 0, 1, 2, 0, 2, 3 };\r\n\r\n\t\tvar mesh = new Mesh( impostor.Material );\r\n\t\tmesh.CreateVertexBuffer( vertices.Length, vertices.AsSpan() );\r\n\t\tmesh.CreateIndexBuffer( indices.Length, indices.AsSpan() );\r\n\r\n\t\t// Culling has to use the whole bounding sphere: the card can be at any angle around it, and the shader\r\n\t\t// places it on the sphere's front face.\r\n\t\tmesh.Bounds = BBox.FromPositionAndSize( c, r * 2.0f );\r\n\t\treturn mesh;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "FlatpackMath.cs",
            "FileName": "FlatpackMath.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\n\r\nnamespace Flatpack;\r\n\r\n/// <summary>\r\n/// How the baked views (\"frames\") are spread over the directions an impostor can be seen from.\r\n/// </summary>\r\npublic enum FlatpackLayout\r\n{\r\n\t/// <summary>\r\n\t/// Hemi-octahedral: the upper half of the sphere. Best for anything that stands on the ground and is never\r\n\t/// seen from below (trees, rocks, props). All N x N frames go to the half you actually see.\r\n\t/// </summary>\r\n\tHemisphere = 0,\r\n\r\n\t/// <summary>\r\n\t/// Full octahedral: every direction. For floating or tumbling objects that can be seen from below.\r\n\t/// </summary>\r\n\tSphere = 1,\r\n\r\n\t/// <summary>\r\n\t/// All N x N frames on the horizon, evenly around Z, and the card only turns around Z. Highest yaw\r\n\t/// resolution, but it can't show the top of the object. Classic tree billboard behaviour.\r\n\t/// </summary>\r\n\tCylinder = 2,\r\n}\r\n\r\n/// <summary>\r\n/// The frame layout maths shared by the baker and the runtime. <c>flatpack_impostor.shader</c> has an HLSL\r\n/// copy of the same functions; they must stay identical or the shader samples the wrong frame.\r\n/// </summary>\r\n/// <remarks>\r\n/// Coordinates are Source/s&amp;box: +X forward, +Y left, +Z up. A frame direction points from the object\r\n/// toward the camera that captured it.\r\n/// </remarks>\r\npublic static class FlatpackMath\r\n{\r\n\t/// <summary>\r\n\t/// Maps a unit direction to the [-1, 1] square for the given layout (not used by <see cref=\"FlatpackLayout.Cylinder\"/>).\r\n\t/// </summary>\r\n\tpublic static Vector2 EncodeOct( Vector3 d, FlatpackLayout layout )\r\n\t{\r\n\t\tif ( layout == FlatpackLayout.Hemisphere )\r\n\t\t{\r\n\t\t\t// Nothing below the horizon was baked, so clamp onto the horizon ring.\r\n\t\t\td.z = MathF.Max( d.z, 0.0f );\r\n\t\t\td = (d + new Vector3( 0, 0, 1e-5f )).Normal;\r\n\t\t\tvar px = d.x / (MathF.Abs( d.x ) + MathF.Abs( d.y ) + d.z);\r\n\t\t\tvar py = d.y / (MathF.Abs( d.x ) + MathF.Abs( d.y ) + d.z);\r\n\r\n\t\t\t// Rotate the octahedron's top half by 45 degrees so it fills the whole square.\r\n\t\t\treturn new Vector2( px + py, px - py );\r\n\t\t}\r\n\r\n\t\tvar sum = MathF.Abs( d.x ) + MathF.Abs( d.y ) + MathF.Abs( d.z );\r\n\t\tvar p = new Vector2( d.x / sum, d.y / sum );\r\n\t\tif ( d.z < 0.0f )\r\n\t\t{\r\n\t\t\t// Fold the lower half out into the square's corners.\r\n\t\t\tp = new Vector2( (1.0f - MathF.Abs( p.y )) * Sign( p.x ), (1.0f - MathF.Abs( p.x )) * Sign( p.y ) );\r\n\t\t}\r\n\r\n\t\treturn p;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Inverse of <see cref=\"EncodeOct\"/>. Returns a unit direction.\r\n\t/// </summary>\r\n\tpublic static Vector3 DecodeOct( Vector2 o, FlatpackLayout layout )\r\n\t{\r\n\t\tif ( layout == FlatpackLayout.Hemisphere )\r\n\t\t{\r\n\t\t\tvar px = (o.x + o.y) * 0.5f;\r\n\t\t\tvar py = (o.x - o.y) * 0.5f;\r\n\t\t\treturn new Vector3( px, py, 1.0f - MathF.Abs( px ) - MathF.Abs( py ) ).Normal;\r\n\t\t}\r\n\r\n\t\tvar d = new Vector3( o.x, o.y, 1.0f - MathF.Abs( o.x ) - MathF.Abs( o.y ) );\r\n\t\tif ( d.z < 0.0f )\r\n\t\t{\r\n\t\t\td.x = (1.0f - MathF.Abs( o.y )) * Sign( o.x );\r\n\t\t\td.y = (1.0f - MathF.Abs( o.x )) * Sign( o.y );\r\n\t\t}\r\n\r\n\t\treturn d.Normal;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The direction frame (<paramref name=\"x\"/>, <paramref name=\"y\"/>) of an <paramref name=\"n\"/> x <paramref name=\"n\"/> grid is baked from.\r\n\t/// </summary>\r\n\tpublic static Vector3 FrameDirection( int x, int y, int n, FlatpackLayout layout )\r\n\t{\r\n\t\tif ( layout == FlatpackLayout.Cylinder )\r\n\t\t{\r\n\t\t\tvar index = y * n + x;\r\n\t\t\tvar yaw = index / (float)(n * n) * MathF.Tau;\r\n\t\t\treturn new Vector3( MathF.Cos( yaw ), MathF.Sin( yaw ), 0.0f );\r\n\t\t}\r\n\r\n\t\t// Frame centres sit on the grid vertices (not cell centres), so the outer frames land exactly on the\r\n\t\t// horizon and the shader can blend between them without extrapolating.\r\n\t\tvar o = new Vector2( x, y ) / (n - 1) * 2.0f - 1.0f;\r\n\t\treturn DecodeOct( o, layout );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The camera basis a frame is captured with. Right is image +X, up is image -Y.\r\n\t/// </summary>\r\n\tpublic static void FrameBasis( Vector3 d, out Vector3 right, out Vector3 up )\r\n\t{\r\n\t\tvar refUp = MathF.Abs( d.z ) < 0.999f ? Vector3.Up : Vector3.Forward;\r\n\t\tright = Vector3.Cross( -d, refUp ).Normal;\r\n\t\tup = Vector3.Cross( right, -d );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Camera rotation for a frame. Equivalent to <see cref=\"FrameBasis\"/> (checked in the editor: <c>Rotation.LookAt</c>\r\n\t/// produces the same right and up vectors).\r\n\t/// </summary>\r\n\tpublic static Rotation FrameRotation( Vector3 d )\r\n\t{\r\n\t\tFrameBasis( d, out _, out var up );\r\n\t\treturn Rotation.LookAt( -d, up );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Smallest sphere-ish bound we bake around: the box centre and its half diagonal. Not the tightest sphere,\r\n\t/// but stable and cheap, and the shader only needs the frames to contain the object.\r\n\t/// </summary>\r\n\tpublic static (Vector3 Center, float Radius) BoundingSphere( BBox bounds )\r\n\t{\r\n\t\treturn (bounds.Center, MathF.Max( bounds.Size.Length * 0.5f, 0.01f ));\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// A bounding sphere fitted to points (Ritter's method): pick two far-apart points, then grow the sphere for any point\r\n\t/// left outside. Within a few percent of the minimal sphere and never smaller than needed.\r\n\t/// </summary>\r\n\tpublic static (Vector3 Center, float Radius) BoundingSphere( IReadOnlyList<Vector3> points )\r\n\t{\r\n\t\tif ( points is null || points.Count == 0 )\r\n\t\t\treturn (Vector3.Zero, 0.01f);\r\n\r\n\t\tvar a = points[0];\r\n\t\tvar b = Farthest( points, a );\r\n\t\tvar c = Farthest( points, b );\r\n\r\n\t\tvar center = (b + c) * 0.5f;\r\n\t\tvar radius = b.Distance( c ) * 0.5f;\r\n\r\n\t\tforeach ( var p in points )\r\n\t\t{\r\n\t\t\tvar d = p.Distance( center );\r\n\t\t\tif ( d <= radius )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\t// Move the centre toward p just enough that the new sphere still touches the old one's far side.\r\n\t\t\tvar newRadius = (radius + d) * 0.5f;\r\n\t\t\tcenter += (p - center) * ((newRadius - radius) / d);\r\n\t\t\tradius = newRadius;\r\n\t\t}\r\n\r\n\t\t// A hair of slack so silhouette texels on the sphere's rim aren't clipped by the frame edge.\r\n\t\treturn (center, MathF.Max( radius * 1.01f, 0.01f ));\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Metres in one world unit. s&amp;box units are inches, as in Source; the iteration 6 distance sheet (\"6 radii (21 m)\"\r\n\t/// for a tree 140 units in radius) used the same conversion.\r\n\t/// </summary>\r\n\tpublic const float MetresPerUnit = 0.0254f;\r\n\r\n\t/// <summary>A distance in world units, in metres.</summary>\r\n\tpublic static float ToMetres( float units ) => units * MetresPerUnit;\r\n\r\n\t/// <summary>\r\n\t/// A distance in world units as a reader wants it next to bounding radii: one decimal under 10 m, whole metres from\r\n\t/// there (\"2.5 m\", \"21 m\"). The inspector, the bake check summary and the benchmark sheets all use this.\r\n\t/// </summary>\r\n\tpublic static string FormatMetres( float units )\r\n\t{\r\n\t\tvar metres = ToMetres( units );\r\n\t\treturn metres < 10.0f\r\n\t\t\t? $\"{metres.ToString( \"0.0\", System.Globalization.CultureInfo.InvariantCulture )} m\"\r\n\t\t\t: $\"{metres.ToString( \"0\", System.Globalization.CultureInfo.InvariantCulture )} m\";\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Columns the frame lookup texture reserves for image rectangles; the depth ranges sit this far to the right.\r\n\t/// The shader's <c>FlatpackFrameFit</c> reads texel (x, y) and (x + 32, y).\r\n\t/// </summary>\r\n\tpublic const int FrameFitColumns = 32;\r\n\r\n\t/// <summary>\r\n\t/// The bounds one frame was baked with: where the object sits inside the bounding sphere's frame, and how deep.\r\n\t/// </summary>\r\n\tpublic struct FrameFit\r\n\t{\r\n\t\t/// <summary>Top-left corner of the frame's image rectangle, in the sphere-sized frame's 0..1 image coordinates (v down).</summary>\r\n\t\tpublic Vector2 Min;\r\n\r\n\t\t/// <summary>Bottom-right corner of the image rectangle.</summary>\r\n\t\tpublic Vector2 Max;\r\n\r\n\t\t/// <summary>Height of the nearest surface along the frame direction (toward its camera), in bounding radii.</summary>\r\n\t\tpublic float Near;\r\n\r\n\t\t/// <summary>Height of the furthest surface, in bounding radii. Always below <see cref=\"Near\"/>.</summary>\r\n\t\tpublic float Far;\r\n\r\n\t\t/// <summary>The whole bounding sphere: what every frame used before iteration 8.</summary>\r\n\t\tpublic static FrameFit Sphere => new() { Min = Vector2.Zero, Max = Vector2.One, Near = 1.0f, Far = -1.0f };\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Fit one frame to the points it sees: their image rectangle, grown by <paramref name=\"padTexels\"/> of a\r\n\t/// <paramref name=\"cellTexels\"/>-wide frame on every side, and their depth range. Rounded outward to the 8-bit steps the\r\n\t/// lookup texture stores, so the baker renders with exactly the bounds the shader reads back.\r\n\t/// </summary>\r\n\tpublic static FrameFit FitFrame( IReadOnlyList<Vector3> points, Vector3 center, float radius, Vector3 dir, int cellTexels, int padTexels )\r\n\t{\r\n\t\tif ( points is null || points.Count == 0 )\r\n\t\t\treturn FrameFit.Sphere;\r\n\r\n\t\tFrameBasis( dir, out var right, out var up );\r\n\t\tfloat minU = float.MaxValue, maxU = float.MinValue, minV = float.MaxValue, maxV = float.MinValue;\r\n\t\tfloat near = float.MinValue, far = float.MaxValue;\r\n\r\n\t\tforeach ( var p in points )\r\n\t\t{\r\n\t\t\tvar rel = p - center;\r\n\t\t\tvar u = 0.5f + Vector3.Dot( rel, right ) / (2.0f * radius);\r\n\t\t\tvar v = 0.5f - Vector3.Dot( rel, up ) / (2.0f * radius);\r\n\t\t\tvar h = Vector3.Dot( rel, dir ) / radius;\r\n\t\t\tminU = MathF.Min( minU, u ); maxU = MathF.Max( maxU, u );\r\n\t\t\tminV = MathF.Min( minV, v ); maxV = MathF.Max( maxV, v );\r\n\t\t\tnear = MathF.Max( near, h ); far = MathF.Min( far, h );\r\n\t\t}\r\n\r\n\t\t// Padding: the rectangle maps onto cellTexels texels, padTexels of which on each side stay outside the object.\r\n\t\tvar grow = cellTexels > 2 * padTexels + 1 ? cellTexels / (float)(cellTexels - 2 * padTexels) : 1.0f;\r\n\t\tvar padU = (maxU - minU) * (grow - 1.0f) * 0.5f;\r\n\t\tvar padV = (maxV - minV) * (grow - 1.0f) * 0.5f;\r\n\r\n\t\tvar fit = new FrameFit\r\n\t\t{\r\n\t\t\tMin = new Vector2( minU - padU, minV - padV ),\r\n\t\t\tMax = new Vector2( maxU + padU, maxV + padV ),\r\n\t\t\tNear = near,\r\n\t\t\tFar = far,\r\n\t\t};\r\n\t\treturn DecodeFrameFit( EncodeFrameFit( fit ) );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The two lookup texels for a frame: (left, top, right) and (bottom, near, far), each value one byte, rounded outward\r\n\t/// so the stored bounds always contain the real ones. Alpha stays 255 (the PNG path drops colour under low alpha).\r\n\t/// </summary>\r\n\tpublic static (Color32 Rect, Color32 Depth) EncodeFrameFit( FrameFit fit )\r\n\t{\r\n\t\tint Down( float v ) => Math.Clamp( (int)MathF.Floor( v * 255.0f + 1e-3f ), 0, 255 );\r\n\t\tint Up( float v ) => Math.Clamp( (int)MathF.Ceiling( v * 255.0f - 1e-3f ), 0, 255 );\r\n\r\n\t\tint minU = Down( fit.Min.x ), maxU = Up( fit.Max.x );\r\n\t\tint minV = Down( fit.Min.y ), maxV = Up( fit.Max.y );\r\n\t\tint near = Up( (fit.Near + 1.0f) * 0.5f ), far = Down( (fit.Far + 1.0f) * 0.5f );\r\n\r\n\t\t// Never let a frame collapse (a flat panel seen edge-on, a plane seen face-on): keep at least two steps each way.\r\n\t\t(minU, maxU) = Widen( minU, maxU );\r\n\t\t(minV, maxV) = Widen( minV, maxV );\r\n\t\t(far, near) = Widen( far, near );\r\n\r\n\t\treturn (new Color32( (byte)minU, (byte)minV, (byte)maxU, 255 ), new Color32( (byte)maxV, (byte)near, (byte)far, 255 ));\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The bounds two lookup texels describe. Matches the shader's <c>FlatpackFrameFit</c>.\r\n\t/// </summary>\r\n\tpublic static FrameFit DecodeFrameFit( (Color32 Rect, Color32 Depth) texels ) => new()\r\n\t{\r\n\t\tMin = new Vector2( texels.Rect.r / 255.0f, texels.Rect.g / 255.0f ),\r\n\t\tMax = new Vector2( texels.Rect.b / 255.0f, texels.Depth.r / 255.0f ),\r\n\t\tNear = texels.Depth.g / 255.0f * 2.0f - 1.0f,\r\n\t\tFar = texels.Depth.b / 255.0f * 2.0f - 1.0f,\r\n\t};\r\n\r\n\tstatic (int Low, int High) Widen( int low, int high )\r\n\t{\r\n\t\twhile ( high - low < 2 )\r\n\t\t{\r\n\t\t\tif ( high < 255 ) high++;\r\n\t\t\tif ( high - low < 2 && low > 0 ) low--;\r\n\t\t}\r\n\t\treturn (low, high);\r\n\t}\r\n\r\n\tstatic Vector3 Farthest( IReadOnlyList<Vector3> points, Vector3 from )\r\n\t{\r\n\t\tvar best = from;\r\n\t\tvar bestDistance = -1.0f;\r\n\t\tforeach ( var p in points )\r\n\t\t{\r\n\t\t\tvar d = p.DistanceSquared( from );\r\n\t\t\tif ( d > bestDistance )\r\n\t\t\t{\r\n\t\t\t\tbestDistance = d;\r\n\t\t\t\tbest = p;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn best;\r\n\t}\r\n\r\n\tstatic float Sign( float v ) => v >= 0.0f ? 1.0f : -1.0f;\r\n}\r\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Code/FlatpackSystem.cs",
            "FileName": "FlatpackSystem.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\n\r\nnamespace Flatpack;\r\n\r\n/// <summary>\r\n/// How hard Flatpack works, for a game's graphics settings: high quality presets turn it off, low ones lean on it.\r\n/// Set <see cref=\"FlatpackSystem.Level\"/>; every Flatpack Renderer follows it within a few frames, nothing re-bakes.\r\n/// </summary>\r\npublic enum FlatpackLevel\r\n{\r\n\t/// <summary>Real models everywhere; cards never show (a game's High or Ultra preset).</summary>\r\n\tOff,\r\n\r\n\t/// <summary>Swap where the bake was measured to look like the model (the default; a game's Medium).</summary>\r\n\tBalanced,\r\n\r\n\t/// <summary>Swap at 0.6 times the distance, from 3 bounding radii (a game's Low).</summary>\r\n\tPerformance,\r\n\r\n\t/// <summary>Swap at 0.35 times the distance, from 1.5 bounding radii (a game's lowest preset or weak hardware).</summary>\r\n\tMaximum,\r\n}\r\n\r\n/// <summary>\r\n/// Runs the model/card distance check for every <see cref=\"FlatpackRenderer\"/> in the scene.\r\n/// </summary>\r\n/// <remarks>\r\n/// One loop per frame instead of one Update per renderer. The loop is time-sliced: each frame checks at most\r\n/// <see cref=\"RenderersPerFrame\"/> renderers, round-robin, so a forest of 5000 trees spreads its checks over a few\r\n/// frames. Renderers in the middle of a cross-fade are ticked every frame until they finish.\r\n/// </remarks>\r\npublic sealed class FlatpackSystem : GameObjectSystem<FlatpackSystem>\r\n{\r\n\t/// <summary>\r\n\t/// Global level for every Flatpack Renderer, meant to be driven by a game's graphics settings, for example\r\n\t/// <c>FlatpackSystem.Level = FlatpackLevel.Performance;</c> on the Low preset. Applies to automatic and manual swap\r\n\t/// distances alike; Force Model and Force Card modes ignore it.\r\n\t/// </summary>\r\n\tpublic static FlatpackLevel Level { get; set; } = FlatpackLevel.Balanced;\r\n\r\n\t/// <summary>\r\n\t/// What <see cref=\"Level\"/> does to a swap distance: a multiplier, and the closest swap allowed in bounding radii\r\n\t/// (0 = the renderer's own minimum). <see cref=\"FlatpackLevel.Off\"/> pushes the swap out to infinity.\r\n\t/// </summary>\r\n\tpublic static (float Scale, float MinimumRadii) LevelScaling => Level switch\r\n\t{\r\n\t\tFlatpackLevel.Off => (float.PositiveInfinity, 0.0f),\r\n\t\tFlatpackLevel.Performance => (0.6f, 3.0f),\r\n\t\tFlatpackLevel.Maximum => (0.35f, 1.5f),\r\n\t\t_ => (1.0f, 0.0f),\r\n\t};\r\n\r\n\t/// <summary>\r\n\t/// Distance checks per frame. Lower spreads the work thinner but makes swaps lag behind a fast camera.\r\n\t/// </summary>\r\n\tpublic int RenderersPerFrame { get; set; } = 2048;\r\n\r\n\t/// <summary>\r\n\t/// Number of registered renderers.\r\n\t/// </summary>\r\n\tpublic int Count => _renderers.Count;\r\n\r\n\treadonly List<FlatpackRenderer> _renderers = new();\r\n\treadonly List<FlatpackRenderer> _fading = new();\r\n\treadonly Dictionary<FlatpackImpostor, int> _visibleCards = new();\r\n\tint _cursor;\r\n\r\n\tpublic FlatpackSystem( Scene scene ) : base( scene )\r\n\t{\r\n\t\t// After the scene update, so this frame's camera and object movement are already in.\r\n\t\tListen( Stage.FinishUpdate, 100, Tick, \"Flatpack.DistancePass\" );\r\n\t}\r\n\r\n\tinternal void Register( FlatpackRenderer renderer )\r\n\t{\r\n\t\tif ( renderer.SystemIndex >= 0 )\r\n\t\t\treturn;\r\n\r\n\t\trenderer.SystemIndex = _renderers.Count;\r\n\t\t_renderers.Add( renderer );\r\n\t}\r\n\r\n\tinternal void Unregister( FlatpackRenderer renderer )\r\n\t{\r\n\t\tvar index = renderer.SystemIndex;\r\n\t\tif ( index < 0 || index >= _renderers.Count || _renderers[index] != renderer )\r\n\t\t\treturn;\r\n\r\n\t\t// Swap-remove keeps this O(1); the moved renderer takes over the freed slot.\r\n\t\tvar last = _renderers[^1];\r\n\t\t_renderers[index] = last;\r\n\t\tlast.SystemIndex = index;\r\n\t\t_renderers.RemoveAt( _renderers.Count - 1 );\r\n\r\n\t\trenderer.SystemIndex = -1;\r\n\t\t_fading.Remove( renderer );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Reference count of visible cards per impostor, kept by <see cref=\"FlatpackRenderer\"/>, so the tick can keep\r\n\t/// those atlases streamed in without walking every renderer.\r\n\t/// </summary>\r\n\tinternal void CardVisibilityChanged( FlatpackImpostor impostor, bool visible )\r\n\t{\r\n\t\tif ( impostor is null )\r\n\t\t\treturn;\r\n\r\n\t\t_visibleCards.TryGetValue( impostor, out var count );\r\n\t\tcount += visible ? 1 : -1;\r\n\r\n\t\tif ( count > 0 )\r\n\t\t\t_visibleCards[impostor] = count;\r\n\t\telse\r\n\t\t\t_visibleCards.Remove( impostor );\r\n\t}\r\n\r\n\tvoid Tick()\r\n\t{\r\n\t\tforeach ( var impostor in _visibleCards.Keys )\r\n\t\t\timpostor.MarkAtlasesUsed();\r\n\r\n\t\tif ( _renderers.Count == 0 )\r\n\t\t\treturn;\r\n\r\n\t\tvar camera = Scene.Camera;\r\n\t\tif ( !camera.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tvar cameraPosition = camera.WorldPosition;\r\n\t\tvar count = Math.Min( RenderersPerFrame, _renderers.Count );\r\n\r\n\t\tfor ( var i = 0; i < count; i++ )\r\n\t\t{\r\n\t\t\tif ( _cursor >= _renderers.Count )\r\n\t\t\t\t_cursor = 0;\r\n\r\n\t\t\tvar renderer = _renderers[_cursor++];\r\n\t\t\tvar distanceSquared = cameraPosition.DistanceSquared( renderer.WorldPosition );\r\n\r\n\t\t\tif ( renderer.Evaluate( distanceSquared ) && !_fading.Contains( renderer ) )\r\n\t\t\t\t_fading.Add( renderer );\r\n\t\t}\r\n\r\n\t\tif ( _fading.Count == 0 )\r\n\t\t\treturn;\r\n\r\n\t\tvar delta = Time.Delta;\r\n\t\tfor ( var i = _fading.Count - 1; i >= 0; i-- )\r\n\t\t{\r\n\t\t\tif ( !_fading[i].TickFade( delta ) )\r\n\t\t\t\t_fading.RemoveAt( i );\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": ".obj/__compiler_extra.cs",
            "FileName": "__compiler_extra.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"Flatpack\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"flatpack\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"anakin\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"anakin.flatpack\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"30\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-10-26T15:38:29.8717994Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.112.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.112.0\")]"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Editor/FlatpackLodModel.cs",
            "FileName": "FlatpackLodModel.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Reflection;\r\nusing System.Threading.Tasks;\r\nusing Editor;\r\nusing Sandbox;\r\n\r\nnamespace Flatpack.EditorTools;\r\n\r\n/// <summary>\r\n/// Builds a model whose last LOD is the Flatpack card: the source model's first LODs with their own materials, then the\r\n/// card. The engine then swaps to the card by itself, like any other LOD, with no Flatpack Renderer on the object, and\r\n/// the Clutter tool can paint it.\r\n/// </summary>\r\n/// <remarks>\r\n/// <para>\r\n/// The engine picks a LOD from the camera distance alone: LOD L shows once <c>switch[L] * scale</c> is below\r\n/// <c>100 * distance * tan(fov / 2) / screenWidth</c> (the native metric, also in <c>clutter_cull_cs.shader</c>). Switch\r\n/// values therefore shift with resolution and field of view; <see cref=\"SwitchValue\"/> converts a world distance using a\r\n/// 1920-pixel-wide, 70 degree reference view.\r\n/// </para>\r\n/// <para>\r\n/// Clutter draws at most four LODs and only the first mesh of each, so the source keeps at most three LODs here and each\r\n/// LOD is one mesh with a draw call per material.\r\n/// </para>\r\n/// </remarks>\r\npublic static class FlatpackLodModel\r\n{\r\n\t/// <summary>Source LODs kept by default: Clutter draws four LODs at most, and the card takes one.</summary>\r\n\tpublic const int ClutterSourceLods = 3;\r\n\r\n\tconst float ReferenceWidth = 1920.0f;\r\n\tconst float ReferenceFov = 70.0f;\r\n\r\n\t/// <summary>\r\n\t/// The LOD switch value that makes the engine swap at <paramref name=\"distance\"/> world units for an unscaled object,\r\n\t/// on the reference view.\r\n\t/// </summary>\r\n\tpublic static float SwitchValue( float distance ) =>\r\n\t\t100.0f * distance * MathF.Tan( ReferenceFov * 0.5f * MathF.PI / 180.0f ) / ReferenceWidth;\r\n\r\n\t/// <summary>\r\n\t/// Build the combined model. <paramref name=\"swapDistance\"/> is where the card takes over, in world units for an\r\n\t/// unscaled object; source LODs that would switch in at or past it are dropped.\r\n\t/// </summary>\r\n\tpublic static Model Build( FlatpackImpostor impostor, float swapDistance, int maxSourceLods = ClutterSourceLods )\r\n\t{\r\n\t\tvar source = impostor.SourceModel ?? throw new ArgumentException( \"The impostor has no source model.\" );\r\n\t\tvar info = source.MeshInfo;\r\n\t\tvar switches = info.LodSwitchDistances;\r\n\t\tvar cardSwitch = SwitchValue( swapDistance );\r\n\r\n\t\tvar lods = Math.Clamp( info.LodCount, 1, Math.Max( 1, maxSourceLods ) );\r\n\t\twhile ( lods > 1 && switches.Length > lods - 1 && switches[lods - 1] >= cardSwitch )\r\n\t\t\tlods--;\r\n\r\n\t\tvar builder = Model.Builder.WithName( $\"{source.ResourceName}_flatpack\" );\r\n\t\tvar bounds = BBox.FromPositionAndSize( impostor.Center, impostor.Radius * 2.0f );\r\n\r\n\t\tfor ( var lod = 0; lod < lods; lod++ )\r\n\t\t{\r\n\t\t\tvar (mesh, meshBounds) = BuildSourceLod( source, info, lod );\r\n\t\t\tbuilder.AddMesh( mesh, lod );\r\n\t\t\tbuilder.WithLodDistance( lod, lod < switches.Length ? switches[lod] : lod * 50.0f );\r\n\t\t\tbounds = bounds.AddBBox( meshBounds );\r\n\t\t}\r\n\r\n\t\t// The card from here to the last slot. Every slot gets a switch value; empty slots past the card would be picked\r\n\t\t// at long range and draw nothing.\r\n\t\tfor ( var lod = lods; lod < 8; lod++ )\r\n\t\t{\r\n\t\t\tbuilder.AddMesh( FlatpackCard.BuildMesh( impostor ), lod );\r\n\t\t\tbuilder.WithLodDistance( lod, cardSwitch );\r\n\t\t}\r\n\r\n\t\treturn builder.WithViewBounds( bounds ).Create();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// One source LOD as a single mesh: every draw call in that LOD, one after another in shared buffers, each with its\r\n\t/// own material.\r\n\t/// </summary>\r\n\tstatic (Mesh Mesh, BBox Bounds) BuildSourceLod( Model source, Model.ModelMeshInfo info, int lod )\r\n\t{\r\n\t\tvar vertices = new List<Vertex>();\r\n\t\tvar indices = new List<int>();\r\n\t\tvar ranges = new List<(Material Material, int Start, int Count)>();\r\n\t\tvar bounds = new BBox( Vector3.Zero, Vector3.Zero );\r\n\t\tvar first = true;\r\n\r\n\t\tfor ( var m = 0; m < info.Meshes.Length; m++ )\r\n\t\t{\r\n\t\t\tvar meshInfo = info.Meshes[m];\r\n\t\t\tif ( ((long)meshInfo.LodMask & (1L << lod)) == 0 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar box = new BBox( meshInfo.BoundsMin, meshInfo.BoundsMax );\r\n\t\t\tbounds = first ? box : bounds.AddBBox( box );\r\n\t\t\tfirst = false;\r\n\r\n\t\t\tfor ( var d = 0; d < meshInfo.DrawCalls.Length; d++ )\r\n\t\t\t{\r\n\t\t\t\tif ( !TryReadDrawCall( source, m, d, out var v, out var ix ) )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar start = indices.Count;\r\n\t\t\t\tvar baseVertex = vertices.Count;\r\n\t\t\t\tvertices.AddRange( v );\r\n\t\t\t\tforeach ( var i in ix )\r\n\t\t\t\t\tindices.Add( (int)i + baseVertex );\r\n\r\n\t\t\t\tranges.Add( (meshInfo.DrawCalls[d].Material, start, ix.Length) );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( ranges.Count == 0 )\r\n\t\t\tthrow new InvalidOperationException( $\"Couldn't read LOD {lod} of {source.ResourceName}.\" );\r\n\r\n\t\tvar mesh = new Mesh( ranges[0].Material );\r\n\t\tmesh.CreateVertexBuffer( vertices.Count, vertices );\r\n\t\tmesh.CreateIndexBuffer( indices.Count, indices );\r\n\t\tmesh.SetIndexRange( ranges[0].Start, ranges[0].Count );\r\n\t\tfor ( var r = 1; r < ranges.Count; r++ )\r\n\t\t\tmesh.AddSubMesh( ranges[r].Material, ranges[r].Start, ranges[r].Count, 0, 0 );\r\n\r\n\t\tmesh.Bounds = bounds;\r\n\t\treturn (mesh, bounds);\r\n\t}\r\n\r\n\tstatic MethodInfo _readDrawCall;\r\n\r\n\t/// <summary>\r\n\t/// The engine's reader for one draw call's vertices and indices. It's internal (the tools' scene compiler uses it), so\r\n\t/// it's reached by reflection; the public whole-model readers don't separate LODs or draw calls.\r\n\t/// </summary>\r\n\tstatic bool TryReadDrawCall( Model model, int mesh, int drawCall, out Vertex[] vertices, out uint[] indices )\r\n\t{\r\n\t\t_readDrawCall ??= typeof( Model ).GetMethod( \"TryReadDrawCall\", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic );\r\n\t\tvertices = null;\r\n\t\tindices = null;\r\n\t\tif ( _readDrawCall is null )\r\n\t\t\treturn false;\r\n\r\n\t\tvar args = new object[] { mesh, drawCall, null, null, null };\r\n\t\tif ( _readDrawCall.Invoke( model, args ) is not true )\r\n\t\t\treturn false;\r\n\r\n\t\tvertices = args[2] as Vertex[];\r\n\t\tindices = args[3] as uint[];\r\n\t\treturn vertices is { Length: > 0 } && indices is { Length: > 0 };\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Save <paramref name=\"model\"/> as a compiled model next to the impostor's <c>.fpack</c> and load it back from disk,\r\n\t/// so scenes and Clutter can reference it by path. Returns the loaded model.\r\n\t/// </summary>\r\n\tpublic static async Task<Model> SaveAsync( FlatpackImpostor impostor, Model model )\r\n\t{\r\n\t\tvar fpack = AssetSystem.FindByPath( impostor.ResourcePath ) ?? throw new InvalidOperationException( \"Save the impostor first.\" );\r\n\t\tvar folder = Path.GetDirectoryName( fpack.AbsolutePath );\r\n\t\tvar name = $\"{impostor.SourceModel.ResourceName}_flatpack\";\r\n\t\tvar file = Path.Combine( folder, $\"{name}.vmdl_c\" );\r\n\r\n\t\tvar bytes = await model.SaveToVmdlAsync();\r\n\t\tawait File.WriteAllBytesAsync( file, bytes );\r\n\r\n\t\tAssetSystem.RegisterFile( file );\r\n\t\tvar relative = Path.Combine( Path.GetDirectoryName( impostor.ResourcePath ) ?? \"\", $\"{name}.vmdl\" ).Replace( '\\\\', '/' );\r\n\t\treturn Model.Load( relative );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Editor/FlatpackMenus.cs",
            "FileName": "FlatpackMenus.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Linq;\nusing Editor;\nusing Sandbox;\n\nnamespace Flatpack.EditorTools;\n\n/// <summary>\n/// Right-click entry points into the baker: on model assets in the asset browser, and on objects in the scene view.\n/// </summary>\npublic static class FlatpackMenus\n{\n\t[Event( \"asset.contextmenu\", Priority = 60 )]\n\tstatic void OnAssetContextMenu( AssetContextMenu e )\n\t{\n\t\tvar models = e.SelectedList\n\t\t\t.Select( x => x.Asset )\n\t\t\t.Where( a => a is not null && a.AssetType == AssetType.Model )\n\t\t\t.ToArray();\n\n\t\tif ( models.Length == 0 )\n\t\t\treturn;\n\n\t\tvar label = models.Length == 1 ? \"Bake Flatpack Impostor\" : $\"Bake {models.Length} Flatpack Impostors\";\n\t\te.Menu.AddOption( label, \"view_in_ar\", () =>\n\t\t{\n\t\t\tvar loaded = models.Select( a => Model.Load( a.Path ) ).Where( m => m is not null && !m.IsError ).ToArray();\n\t\t\tFlatpackBakeWindow.Open( loaded );\n\t\t} );\n\t}\n\n\t/// <summary>\n\t/// Bake the model on <paramref name=\"renderer\"/> with the window's current settings, then put a\n\t/// <see cref=\"FlatpackRenderer\"/> next to it pointing at the result (undoable).\n\t/// </summary>\n\tpublic static async void BakeAndAttach( ModelRenderer renderer )\n\t{\n\t\tif ( !renderer.IsValid() || renderer.Model is null )\n\t\t\treturn;\n\n\t\tvar window = FlatpackBakeWindow.Open( renderer.Model );\n\t\tif ( window.IsBaking )\n\t\t\treturn;\n\n\t\tvar impostors = await window.BakeQueueAsync();\n\t\tvar impostor = impostors.FirstOrDefault( x => x.SourceModel == renderer.Model );\n\t\tif ( impostor is null || !renderer.IsValid() )\n\t\t\treturn;\n\n\t\tvar go = renderer.GameObject;\n\t\tvar session = SceneEditorSession.Resolve( go );\n\t\tif ( session is null )\n\t\t\treturn;\n\n\t\tusing var scene = session.Scene.Push();\n\t\tusing ( session.UndoScope( \"Add Flatpack Renderer\" ).WithComponentCreations().WithComponentChanges( go.Components.GetAll<FlatpackRenderer>() ).Push() )\n\t\t{\n\t\t\tvar flatpack = go.Components.GetOrCreate<FlatpackRenderer>();\n\t\t\tflatpack.Source = renderer;\n\t\t\tflatpack.Impostor = impostor;\n\t\t}\n\t}\n}\n\n/// <summary>\n/// Adds \"Bake Flatpack Impostor\" to the scene viewport's right-click menu when you click an object with a\n/// <see cref=\"ModelRenderer\"/>. Scene view hooks are instance interfaces, so one listener is registered on first use.\n/// </summary>\nsealed class FlatpackSceneViewHooks : EditorEvent.ISceneView\n{\n\tstatic FlatpackSceneViewHooks _instance;\n\n\t[EditorEvent.Frame]\n\tstatic void EnsureRegistered()\n\t{\n\t\tif ( _instance is not null )\n\t\t\treturn;\n\n\t\t_instance = new FlatpackSceneViewHooks();\n\t\tEditorEvent.Register( _instance );\n\t}\n\n\tvoid EditorEvent.ISceneView.DrawGizmos( Scene scene ) { }\n\n\tvoid EditorEvent.ISceneView.ShowContextMenu( EditorEvent.ShowContextMenuEvent ev )\n\t{\n\t\tvar target = ev.Trace?.GameObject;\n\t\tvar renderer = target?.Components.Get<ModelRenderer>( FindMode.EverythingInSelfAndAncestors );\n\t\tif ( !renderer.IsValid() || renderer.Model is null )\n\t\t\treturn;\n\n\t\tev.Menu.AddSeparator();\n\t\tev.Menu.AddOption( $\"Bake Flatpack Impostor ({renderer.Model.ResourceName})\", \"view_in_ar\", () => FlatpackMenus.BakeAndAttach( renderer ) );\n\t}\n}\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Editor/FlatpackRendererEditor.cs",
            "FileName": "FlatpackRendererEditor.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Linq;\nusing Editor;\nusing Sandbox;\n\nnamespace Flatpack.EditorTools;\n\n/// <summary>\n/// Inspector for <see cref=\"FlatpackRenderer\"/>: the usual properties plus a bake button, so giving one model a\n/// Flatpack LOD is \"add component, press Bake\".\n/// </summary>\n[CustomEditor( typeof( FlatpackRenderer ) )]\npublic sealed class FlatpackRendererEditor : ComponentEditorWidget\n{\n\tpublic FlatpackRendererEditor( SerializedObject obj ) : base( obj )\n\t{\n\t\tSetSizeMode( SizeMode.Default, SizeMode.Default );\n\n\t\tLayout = Layout.Column();\n\n\t\tvar sheet = new ControlSheet();\n\t\t// Only the component's [Property] members; the default sheet hides transform and internals the same way.\n\t\tsheet.AddObject( obj, p => p.HasAttribute<PropertyAttribute>() );\n\t\tLayout.Add( sheet );\n\n\t\tvar renderers = obj.Targets.OfType<FlatpackRenderer>().ToArray();\n\t\tvar baked = renderers.All( x => x.Impostor is not null );\n\n\t\t// What the bake is, how close it holds up, and whether the swap distance is inside that.\n\t\tif ( renderers.Length == 1 && renderers[0].Impostor is { } impostor )\n\t\t{\n\t\t\tvar r = renderers[0];\n\n\t\t\t// Radii are the bake's own bounding radius times this object's scale, so metres follow the object's real size.\n\t\t\tvar scale = r.WorldScale;\n\t\t\tvar radius = impostor.Radius * MathF.Max( scale.x, MathF.Max( scale.y, scale.z ) );\n\t\t\tvar swapRadii = radius > 0 ? r.EffectiveSwapDistance / radius : 0;\n\t\t\tvar holds = impostor.HoldsUpFrom switch\n\t\t\t{\n\t\t\t\t> 0 => $\"matches the model from {impostor.HoldsUpFrom:0.#} radii ({FlatpackMath.FormatMetres( impostor.HoldsUpFrom * radius )})\",\n\t\t\t\t< 0 => $\"never matches the model closely inside 16 radii ({FlatpackMath.FormatMetres( 16.0f * radius )})\",\n\t\t\t\t_ => \"not measured (re-bake to measure)\",\n\t\t\t};\n\t\t\tvar level = string.IsNullOrEmpty( impostor.QualityLevel ) ? \"\" : $\"{impostor.QualityLevel} bake, \";\n\t\t\tvar info = Layout.Add( new Label( $\"{level}{holds}. Swaps at {swapRadii:0.#} radii ({FlatpackMath.FormatMetres( r.EffectiveSwapDistance )}).\" ) { WordWrap = true } );\n\t\t\tinfo.ContentMargins = new Sandbox.UI.Margin( 8, 4, 8, 0 );\n\t\t\tif ( impostor.HoldsUpFrom < 0 )\n\t\t\t\tinfo.SetStyles( \"color: #e0a030;\" );\n\t\t}\n\n\t\tvar row = Layout.AddRow();\n\t\trow.Margin = new Sandbox.UI.Margin( 8, 4, 8, 8 );\n\t\trow.Spacing = 4;\n\n\t\trow.Add( new Button( baked ? \"Re-bake Impostor\" : \"Bake Impostor\", \"view_in_ar\" )\n\t\t{\n\t\t\tToolTip = \"Bake this object's model with the Impostor Baker's current settings and assign the result.\",\n\t\t\tClicked = () =>\n\t\t\t{\n\t\t\t\tforeach ( var renderer in renderers )\n\t\t\t\t{\n\t\t\t\t\tvar source = renderer.Source ?? renderer.GameObject.Components.Get<ModelRenderer>();\n\t\t\t\t\tif ( source.IsValid() )\n\t\t\t\t\t\tFlatpackMenus.BakeAndAttach( source );\n\t\t\t\t}\n\t\t\t}\n\t\t} );\n\n\t\trow.Add( new Button( \"Bake Settings\", \"tune\" )\n\t\t{\n\t\t\tToolTip = \"Open the Impostor Baker window to change grid, atlas size and layout.\",\n\t\t\tClicked = () => FlatpackBakeWindow.Open()\n\t\t} );\n\n\t\trow.AddStretchCell();\n\t}\n}\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Code/FlatpackCard.cs",
            "FileName": "FlatpackCard.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace Flatpack;\r\n\r\n/// <summary>\r\n/// Builds the card mesh an impostor is drawn with.\r\n/// </summary>\r\npublic static class FlatpackCard\r\n{\r\n\t/// <summary>\r\n\t/// One quad, two triangles, with the impostor material. The vertex shader moves the corners every frame to\r\n\t/// face the camera, using only the UVs; the positions here are a plausible fallback (a quad facing +X through the\r\n\t/// bounds centre) for anything that reads the mesh on the CPU.\r\n\t/// </summary>\r\n\tpublic static Model Build( FlatpackImpostor impostor )\r\n\t{\r\n\t\treturn Model.Builder\r\n\t\t\t.WithName( $\"flatpack_card_{impostor.ResourceName}\" )\r\n\t\t\t.AddMesh( BuildMesh( impostor ) )\r\n\t\t\t.Create();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The card quad as a mesh, for adding to another model (the card as the last LOD of a model).\r\n\t/// </summary>\r\n\tpublic static Mesh BuildMesh( FlatpackImpostor impostor )\r\n\t{\r\n\t\tvar c = impostor.Center;\r\n\t\tvar r = impostor.Radius;\r\n\r\n\t\tvar normal = Vector3.Forward;\r\n\t\tvar tangent = Vector3.Left;\r\n\r\n\t\t// UV (0,0) is the top-left corner; the shader maps u to the card's right and v to its down.\r\n\t\tvar vertices = new[]\r\n\t\t{\r\n\t\t\tnew Vertex( c + new Vector3( 0, -r, r ), normal, tangent, new Vector4( 0, 0, 0, 0 ) ),\r\n\t\t\tnew Vertex( c + new Vector3( 0, r, r ), normal, tangent, new Vector4( 1, 0, 0, 0 ) ),\r\n\t\t\tnew Vertex( c + new Vector3( 0, r, -r ), normal, tangent, new Vector4( 1, 1, 0, 0 ) ),\r\n\t\t\tnew Vertex( c + new Vector3( 0, -r, -r ), normal, tangent, new Vector4( 0, 1, 0, 0 ) ),\r\n\t\t};\r\n\r\n\t\tvar indices = new[] { 0, 1, 2, 0, 2, 3 };\r\n\r\n\t\tvar mesh = new Mesh( impostor.Material );\r\n\t\tmesh.CreateVertexBuffer( vertices.Length, vertices.AsSpan() );\r\n\t\tmesh.CreateIndexBuffer( indices.Length, indices.AsSpan() );\r\n\r\n\t\t// Culling has to use the whole bounding sphere: the card can be at any angle around it, and the shader\r\n\t\t// places it on the sphere's front face.\r\n\t\tmesh.Bounds = BBox.FromPositionAndSize( c, r * 2.0f );\r\n\t\treturn mesh;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Code/FlatpackImpostor.cs",
            "FileName": "FlatpackImpostor.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System.Linq;\nusing System.Text.Json.Serialization;\nusing Sandbox;\n\nnamespace Flatpack;\n\n/// <summary>\n/// A baked impostor: the atlas material plus everything the runtime needs to place the card.\n/// Created by the Flatpack baker (Editor menu, Flatpack, Impostor Baker), one per source model.\n/// </summary>\n/// <remarks>\n/// <para>Use it with <see cref=\"FlatpackRenderer\"/>, which swaps between the real model and the card by distance.</para>\n/// <para>\n/// To use the card as the last LOD of a <c>.vmdl</c> instead, add a LOD to the model in ModelDoc whose only mesh is a\n/// single quad (any size, centred on the model) with this impostor's material, UVs (0,0) top-left to (1,1)\n/// bottom-right. The shader ignores the quad's positions and rebuilds the card from its UVs, the bake bounds and the\n/// instance transform, so the quad's size and orientation don't matter. Flatpack doesn't write <c>.vmdl</c> files for you.\n/// </para>\n/// </remarks>\n[AssetType( Name = \"Flatpack Impostor\", Extension = \"fpack\", Category = \"Rendering\" )]\npublic sealed class FlatpackImpostor : GameResource\n{\n\t/// <summary>\n\t/// The model this impostor was baked from.\n\t/// </summary>\n\t[Property] public Model SourceModel { get; set; }\n\n\t/// <summary>\n\t/// Material using <c>shaders/flatpack_impostor.shader</c> with the baked atlases.\n\t/// </summary>\n\t[Property] public Material Material { get; set; }\n\n\t/// <summary>\n\t/// How the frames are spread over view directions.\n\t/// </summary>\n\t[Property, ReadOnly] public FlatpackLayout Layout { get; set; } = FlatpackLayout.Hemisphere;\n\n\t/// <summary>\n\t/// Frames per atlas side. The atlas holds GridSize x GridSize frames.\n\t/// </summary>\n\t[Property, ReadOnly] public int GridSize { get; set; } = 8;\n\n\t/// <summary>\n\t/// Atlas width and height in pixels.\n\t/// </summary>\n\t[Property, ReadOnly] public int AtlasSize { get; set; } = 2048;\n\n\t/// <summary>\n\t/// Centre of the baked bounding sphere, in the model's object space (the pivot offset).\n\t/// </summary>\n\t[Property, ReadOnly] public Vector3 Center { get; set; }\n\n\t/// <summary>\n\t/// Radius of the baked bounding sphere, in object space units.\n\t/// </summary>\n\t[Property, ReadOnly] public float Radius { get; set; } = 1.0f;\n\n\t/// <summary>\n\t/// The source model's render bounds at bake time.\n\t/// </summary>\n\t[Property, ReadOnly] public BBox Bounds { get; set; }\n\n\t/// <summary>\n\t/// How much alpha the baker had to add back at mip level 2 to keep thin parts covered (1 = none). High values mean\n\t/// twigs, spindles or wire mesh that the card keeps only from further away; the scene optimiser warns about them.\n\t/// </summary>\n\t[Property, ReadOnly] public float ThinDetail { get; set; } = 1.0f;\n\n\t/// <summary>\n\t/// Quality level the bake was made at (Low, Medium, High, Ultra or Custom).\n\t/// </summary>\n\t[Property, ReadOnly] public string QualityLevel { get; set; }\n\n\t/// <summary>\n\t/// Nearest distance, in bounding radii, from which the baker measured the card matching its model: at most 15% of\n\t/// pixels clearly different from there out to 16 radii. 0 when not measured, -1 when it never got there.\n\t/// <see cref=\"FlatpackRenderer\"/>'s automatic swap distance never goes closer than this.\n\t/// </summary>\n\t[Property, ReadOnly] public float HoldsUpFrom { get; set; }\n\n\t/// <summary>\n\t/// Share of the covered texels that are only partly covered (0 to 1). Above <c>0.4</c> the bake is treated as\n\t/// see-through detail (wire mesh, twigs, nets) and drawn with a lowered alpha cut the baker fits to the model.\n\t/// </summary>\n\t[Property, ReadOnly] public float SeeThrough { get; set; }\n\n\tModel _cardModel;\n\tMaterial _cardMaterial;\n\n\t/// <summary>\n\t/// The shared card model for this impostor: one quad with the impostor material. Built on first use and reused\n\t/// by every <see cref=\"FlatpackRenderer\"/> that points here, so identical cards can be instanced by the engine.\n\t/// </summary>\n\t[JsonIgnore, Hide]\n\tpublic Model CardModel\n\t{\n\t\tget\n\t\t{\n\t\t\tif ( _cardModel is not null && _cardMaterial == Material )\n\t\t\t\treturn _cardModel;\n\n\t\t\t_cardMaterial = Material;\n\t\t\t_cardModel = FlatpackCard.Build( this );\n\t\t\treturn _cardModel;\n\t\t}\n\t}\n\n\tTexture[] _atlases;\n\tMaterial _atlasMaterial;\n\n\t/// <summary>\n\t/// Tell texture streaming this impostor's atlases are on screen and needed at full size.\n\t/// </summary>\n\t/// <remarks>\n\t/// The streamer can't judge the card's real texel density: the card shows one frame (1/GridSize of the atlas)\n\t/// across the whole quad, and the shader picks UVs itself. Left alone it kept a 2048 atlas at 512 or 256 and the\n\t/// impostor looked blurred. <see cref=\"FlatpackSystem\"/> calls this every frame for impostors with a visible card.\n\t/// </remarks>\n\tpublic void MarkAtlasesUsed()\n\t{\n\t\tif ( Material is null )\n\t\t\treturn;\n\n\t\tif ( _atlases is null || _atlasMaterial != Material )\n\t\t{\n\t\t\t_atlasMaterial = Material;\n\t\t\t// Bakes from before iteration 8 use the combined normal and depth atlas; later ones the separate pair.\n\t\t\t_atlases = new[] { \"g_tFlatpackAlbedo\", \"g_tFlatpackNormalDepth\", \"g_tFlatpackNormal\", \"g_tFlatpackDepth\", \"g_tFlatpackDepth8\", \"g_tFlatpackDepthMin\", \"g_tFlatpackFrames\", \"g_tFlatpackRoughnessAo\" }\n\t\t\t\t.Select( Material.GetTexture )\n\t\t\t\t.Where( t => t is not null )\n\t\t\t\t.ToArray();\n\t\t}\n\n\t\tforeach ( var texture in _atlases )\n\t\t\ttexture.MarkUsed( AtlasSize );\n\t}\n\n\tprotected override void PostReload()\n\t{\n\t\tbase.PostReload();\n\n\t\t// Rebuild the card next time it's asked for, in case a re-bake changed the bounds.\n\t\t_cardModel = null;\n\t\t_atlases = null;\n\t}\n}\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "FlatpackRenderer.cs",
            "FileName": "FlatpackRenderer.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace Flatpack;\r\n\r\n/// <summary>\r\n/// One mesh LOD in a <see cref=\"FlatpackRenderer\"/> chain: a model and the camera distance it takes over from.\r\n/// </summary>\r\npublic sealed class FlatpackLodLevel\r\n{\r\n\t/// <summary>\r\n\t/// The model to show in this band. Usually a lower-poly version of the source.\r\n\t/// </summary>\r\n\t[Property] public Model Model { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// Camera distance where this level replaces the one before it, in world units.\r\n\t/// </summary>\r\n\t[Property, Range( 0, 20000 )] public float Distance { get; set; } = 1000.0f;\r\n}\r\n\r\n/// <summary>\r\n/// Shows a model up close and its Flatpack impostor card far away.\r\n/// </summary>\r\n/// <remarks>\r\n/// <para>\r\n/// Put this next to a <see cref=\"ModelRenderer\"/> and assign a baked <see cref=\"FlatpackImpostor\"/>. The renderer\r\n/// doesn't tick on its own: <see cref=\"FlatpackSystem\"/> checks distances for every renderer in one batched,\r\n/// time-sliced pass, so thousands of instances cost one loop instead of thousands of updates.\r\n/// </para>\r\n/// <para>\r\n/// All cards made from the same impostor share one model and material, so the engine can instance them.\r\n/// </para>\r\n/// <para>\r\n/// LODs: the source <c>.vmdl</c>'s own LODs keep working up close, because this component never touches the\r\n/// <see cref=\"ModelRenderer\"/> until the swap. For separate model files per detail level, fill <see cref=\"Levels\"/>:\r\n/// the renderer swaps the source's model by distance (with the same hysteresis), then hands over to the card past\r\n/// the swap distance. With no <see cref=\"ModelRenderer\"/> at all the card shows at every distance.\r\n/// </para>\r\n/// <para>\r\n/// Alternative without this component: make the card the last LOD of the <c>.vmdl</c> (see <see cref=\"FlatpackImpostor\"/>).\r\n/// That gives you engine LOD switching for free, but no hysteresis and no cross-fade.\r\n/// </para>\r\n/// </remarks>\r\n[Title( \"Flatpack Renderer\" ), Category( \"Rendering\" ), Icon( \"view_in_ar\" )]\r\npublic sealed class FlatpackRenderer : Component, Component.ExecuteInEditor\r\n{\r\n\t/// <summary>\r\n\t/// What to draw.\r\n\t/// </summary>\r\n\tpublic enum DisplayMode\r\n\t{\r\n\t\t/// <summary>Model inside <see cref=\"EffectiveSwapDistance\"/>, card beyond it.</summary>\r\n\t\tAuto,\r\n\t\t/// <summary>Always the real model.</summary>\r\n\t\tForceModel,\r\n\t\t/// <summary>Always the card. Useful for checking a bake.</summary>\r\n\t\tForceCard,\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The baked impostor to show at distance. Left empty, the renderer uses any baked impostor whose source model\r\n\t/// matches the <see cref=\"Source\"/> renderer's model, so adding the component after a bake needs no setup.\r\n\t/// </summary>\r\n\t[Property] public FlatpackImpostor Impostor { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// The renderer for the real model. Found on this GameObject if left empty.\r\n\t/// </summary>\r\n\t[Property] public ModelRenderer Source { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// Pick the swap distance from the object's size: the card takes over once the bounding sphere is about\r\n\t/// 1/<see cref=\"AutoDistanceFactor\"/> of the distance across, roughly 5% of a 1080p screen's height at 70 degrees FOV.\r\n\t/// </summary>\r\n\t[Property] public bool AutoSwapDistance { get; set; } = true;\r\n\r\n\t/// <summary>\r\n\t/// Auto mode swaps as soon as the card looks like the model: at the distance the baker measured\r\n\t/// (<see cref=\"FlatpackImpostor.HoldsUpFrom\"/>, calibrated by eye), never closer than <see cref=\"MinimumSwapRadii\"/>.\r\n\t/// Measured in iteration 6: a 534,000-triangle tree forest drew in 7 to 13 ms swapping at 8 radii against 25 to 28 ms\r\n\t/// at 30. Turn it off to swap at <see cref=\"AutoDistanceFactor\"/> instead (bakes that weren't measured always do).\r\n\t/// </summary>\r\n\t[Property, ShowIf( nameof( AutoSwapDistance ), true )] public bool SwapWhenItLooksRight { get; set; } = true;\r\n\r\n\t/// <summary>\r\n\t/// The closest an early swap may happen, in bounding radii. Up close a card covers many pixels and costs more to\r\n\t/// draw, so very light models can end up slower as cards than as models.\r\n\t/// </summary>\r\n\t[Property, Range( 2, 30 ), ShowIf( nameof( SwapWhenItLooksRight ), true )] public float MinimumSwapRadii { get; set; } = 6.0f;\r\n\r\n\t/// <summary>\r\n\t/// Auto mode swaps at this many bounding-sphere radii from the camera when it isn't swapping early (or the bake\r\n\t/// wasn't measured), or where the bake holds up if that's further.\r\n\t/// </summary>\r\n\t[Property, Range( 5, 100 ), ShowIf( nameof( AutoSwapDistance ), true )] public float AutoDistanceFactor { get; set; } = 30.0f;\r\n\r\n\t/// <summary>\r\n\t/// Camera distance where the card takes over, in world units, when <see cref=\"AutoSwapDistance\"/> is off.\r\n\t/// </summary>\r\n\t[Property, Range( 0, 20000 ), ShowIf( nameof( AutoSwapDistance ), false )] public float SwapDistance { get; set; } = 2000.0f;\r\n\r\n\t/// <summary>\r\n\t/// The swap distance in use: <see cref=\"SwapDistance\"/>, or the automatic one, then scaled by the global\r\n\t/// <see cref=\"FlatpackSystem.Level\"/> (a game's graphics setting).\r\n\t/// </summary>\r\n\tpublic float EffectiveSwapDistance\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar (levelScale, levelMinimum) = FlatpackSystem.LevelScaling;\r\n\t\t\tif ( float.IsPositiveInfinity( levelScale ) )\r\n\t\t\t\treturn float.PositiveInfinity;\r\n\r\n\t\t\tif ( !AutoSwapDistance || Impostor is null )\r\n\t\t\t\treturn SwapDistance * levelScale;\r\n\r\n\t\t\tvar scale = WorldScale;\r\n\t\t\tvar radius = Impostor.Radius * MathF.Max( scale.x, MathF.Max( scale.y, scale.z ) );\r\n\t\t\tvar measured = Impostor.HoldsUpFrom > 0.0f;\r\n\t\t\tvar factor = SwapWhenItLooksRight && measured\r\n\t\t\t\t? MathF.Max( Impostor.HoldsUpFrom, MinimumSwapRadii )\r\n\t\t\t\t: MathF.Max( AutoDistanceFactor, Impostor.HoldsUpFrom );\r\n\r\n\t\t\t// A lower graphics level swaps earlier, but never closer than its own minimum.\r\n\t\t\tfactor *= levelScale;\r\n\t\t\tif ( levelMinimum > 0.0f )\r\n\t\t\t\tfactor = MathF.Max( factor, levelMinimum );\r\n\t\t\treturn radius * factor;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Width of the dead band around <see cref=\"SwapDistance\"/>. The card appears past SwapDistance + Hysteresis / 2\r\n\t/// and the model returns inside SwapDistance - Hysteresis / 2, so a camera hovering at the boundary doesn't flicker.\r\n\t/// </summary>\r\n\t[Property, Range( 0, 2000 )] public float Hysteresis { get; set; } = 200.0f;\r\n\r\n\t/// <summary>\r\n\t/// Dither the card in and out over <see cref=\"FadeTime\"/> instead of popping. The model stays visible under the\r\n\t/// card until the fade-in finishes, so the swap reads as a dissolve.\r\n\t/// </summary>\r\n\t[Property] public bool CrossFade { get; set; } = true;\r\n\r\n\t/// <summary>\r\n\t/// Seconds a cross-fade takes.\r\n\t/// </summary>\r\n\t[Property, Range( 0, 2 ), ShowIf( nameof( CrossFade ), true )] public float FadeTime { get; set; } = 0.35f;\r\n\r\n\t/// <summary>\r\n\t/// Override the distance check.\r\n\t/// </summary>\r\n\t[Property] public DisplayMode Mode { get; set; } = DisplayMode.Auto;\r\n\r\n\t/// <summary>\r\n\t/// Optional mesh LODs between the source model and the card, nearest first. Each replaces the source renderer's\r\n\t/// model past its <see cref=\"FlatpackLodLevel.Distance\"/>. Distances past <see cref=\"SwapDistance\"/> never show.\r\n\t/// </summary>\r\n\t[Property] public List<FlatpackLodLevel> Levels { get; set; } = new();\r\n\r\n\t/// <summary>\r\n\t/// Index into <see cref=\"Levels\"/> currently on the source renderer, or -1 for the source's own model.\r\n\t/// </summary>\r\n\tpublic int CurrentLevel { get; private set; } = -1;\r\n\r\n\t/// <summary>\r\n\t/// True while the card is the visible representation (including while it fades in).\r\n\t/// </summary>\r\n\tpublic bool ShowingCard { get; private set; }\r\n\r\n\tSceneObject _card;\r\n\tModel _baseModel;      // the source renderer's own model, restored when the chain steps back to level -1\r\n\tFlatpackImpostor _countedImpostor;   // impostor this renderer's visible card is counted against in the system\r\n\tfloat _fade;           // 0 = card hidden, 1 = card fully shown\r\n\tfloat _fadeTarget;\r\n\tinternal int SystemIndex = -1;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tSource ??= GameObject.Components.Get<ModelRenderer>();\r\n\t\t_baseModel = Source.IsValid() ? Source.Model : null;\r\n\t\tImpostor ??= FindImpostorFor( _baseModel );\r\n\t\tCurrentLevel = -1;\r\n\t\tTransform.OnTransformChanged += SyncTransform;\r\n\r\n\t\tScene.GetSystem<FlatpackSystem>()?.Register( this );\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tTransform.OnTransformChanged -= SyncTransform;\r\n\t\tScene.GetSystem<FlatpackSystem>()?.Unregister( this );\r\n\r\n\t\tDestroyCard();\r\n\t\tSetLevel( -1 );\r\n\t\tSetSourceVisible( true );\r\n\t\tShowingCard = false;\r\n\t\t_fade = _fadeTarget = 0.0f;\r\n\t}\r\n\r\n\tprotected override void OnValidate()\r\n\t{\r\n\t\t// An edited impostor or a changed mode should show up straight away in the editor.\r\n\t\tDestroyCard();\r\n\t\tSetLevel( -1 );\r\n\t\tShowingCard = false;\r\n\t\t_fade = _fadeTarget = 0.0f;\r\n\t\tSetSourceVisible( true );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Called by <see cref=\"FlatpackSystem\"/> with the squared distance from the camera to this object.\r\n\t/// Decides which representation should be showing; the fade itself runs in <see cref=\"TickFade\"/>.\r\n\t/// </summary>\r\n\tinternal bool Evaluate( float distanceSquared )\r\n\t{\r\n\t\tUpdateLevel( distanceSquared );\r\n\r\n\t\tvar wantCard = Mode switch\r\n\t\t{\r\n\t\t\tDisplayMode.ForceCard => true,\r\n\t\t\tDisplayMode.ForceModel => false,\r\n\t\t\t_ => WantCardAt( distanceSquared ),\r\n\t\t};\r\n\r\n\t\t// Card-only setup: nothing else to show.\r\n\t\tif ( !Source.IsValid() && Mode != DisplayMode.ForceModel )\r\n\t\t\twantCard = true;\r\n\r\n\t\tif ( wantCard && Impostor?.Material is null )\r\n\t\t\twantCard = false;\r\n\r\n\t\tif ( wantCard == ShowingCard )\r\n\t\t\treturn false;\r\n\r\n\t\tShowingCard = wantCard;\r\n\t\t_fadeTarget = wantCard ? 1.0f : 0.0f;\r\n\r\n\t\tif ( wantCard )\r\n\t\t\tEnsureCard();\r\n\r\n\t\tif ( !CrossFade || FadeTime <= 0.0f )\r\n\t\t\t_fade = _fadeTarget;\r\n\r\n\t\tApply();\r\n\r\n\t\t// The system keeps calling TickFade while this returns true.\r\n\t\treturn _fade != _fadeTarget;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Step the mesh LOD chain toward the level for this distance, one band edge at a time, each with the same\r\n\t/// hysteresis band as the card swap.\r\n\t/// </summary>\r\n\tvoid UpdateLevel( float distanceSquared )\r\n\t{\r\n\t\tif ( Levels is null || Levels.Count == 0 || !Source.IsValid() || Mode != DisplayMode.Auto )\r\n\t\t\treturn;\r\n\r\n\t\tvar level = Math.Min( CurrentLevel, Levels.Count - 1 );\r\n\t\tvar half = Hysteresis * 0.5f;\r\n\r\n\t\twhile ( level + 1 < Levels.Count && Beyond( distanceSquared, Levels[level + 1].Distance + half ) )\r\n\t\t\tlevel++;\r\n\r\n\t\twhile ( level >= 0 && !Beyond( distanceSquared, Levels[level].Distance - half ) )\r\n\t\t\tlevel--;\r\n\r\n\t\tSetLevel( level );\r\n\t}\r\n\r\n\tstatic bool Beyond( float distanceSquared, float distance )\r\n\t{\r\n\t\tdistance = MathF.Max( distance, 0.0f );\r\n\t\treturn distanceSquared > distance * distance;\r\n\t}\r\n\r\n\tvoid SetLevel( int level )\r\n\t{\r\n\t\tif ( level == CurrentLevel || !Source.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tCurrentLevel = level;\r\n\t\tvar model = level >= 0 ? Levels[level].Model : _baseModel;\r\n\t\tif ( model is not null )\r\n\t\t\tSource.Model = model;\r\n\r\n\t\t// A new model can mean a new scene object, which starts visible.\r\n\t\tSetSourceVisible( !ShowingCard || _fade < 1.0f );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The first baked impostor whose source is <paramref name=\"model\"/>, or null.\r\n\t/// </summary>\r\n\tpublic static FlatpackImpostor FindImpostorFor( Model model )\r\n\t{\r\n\t\tif ( model is null )\r\n\t\t\treturn null;\r\n\r\n\t\treturn ResourceLibrary.GetAll<FlatpackImpostor>()\r\n\t\t\t.FirstOrDefault( x => x.SourceModel is not null && x.SourceModel.ResourcePath == model.ResourcePath );\r\n\t}\r\n\r\n\tbool WantCardAt( float distanceSquared )\r\n\t{\r\n\t\t// Hysteresis: the threshold depends on which side we're already on.\r\n\t\tvar swap = EffectiveSwapDistance;\r\n\t\tvar threshold = ShowingCard\r\n\t\t\t? swap - Hysteresis * 0.5f\r\n\t\t\t: swap + Hysteresis * 0.5f;\r\n\r\n\t\tthreshold = MathF.Max( threshold, 0.0f );\r\n\t\treturn distanceSquared > threshold * threshold;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Advances a running cross-fade. Returns false when it has finished.\r\n\t/// </summary>\r\n\tinternal bool TickFade( float delta )\r\n\t{\r\n\t\tvar step = FadeTime > 0.0f ? delta / FadeTime : 1.0f;\r\n\t\t_fade = _fadeTarget > _fade\r\n\t\t\t? MathF.Min( _fade + step, _fadeTarget )\r\n\t\t\t: MathF.Max( _fade - step, _fadeTarget );\r\n\r\n\t\tApply();\r\n\t\treturn _fade != _fadeTarget;\r\n\t}\r\n\r\n\tvoid Apply()\r\n\t{\r\n\t\tif ( _card.IsValid() )\r\n\t\t{\r\n\t\t\t_card.RenderingEnabled = _fade > 0.0f;\r\n\t\t\tCountCard( _fade > 0.0f ? Impostor : null );\r\n\r\n\t\t\t// 1 draws every pixel; anything lower dithers the card out (see flatpack_impostor.shader).\r\n\t\t\t_card.Attributes.Set( \"FlatpackFade\", _fade );\r\n\t\t}\r\n\r\n\t\t// The model stays up until the card has fully faded in, and comes back the moment we head toward it.\r\n\t\tSetSourceVisible( !ShowingCard || _fade < 1.0f );\r\n\t}\r\n\r\n\tvoid SetSourceVisible( bool visible )\r\n\t{\r\n\t\tif ( Source.IsValid() && Source.SceneObject.IsValid() )\r\n\t\t\tSource.SceneObject.RenderingEnabled = visible && Source.Enabled;\r\n\t}\r\n\r\n\tvoid EnsureCard()\r\n\t{\r\n\t\tif ( _card.IsValid() || Impostor is null )\r\n\t\t\treturn;\r\n\r\n\t\t_card = new SceneObject( Scene.SceneWorld, Impostor.CardModel, WorldTransform );\r\n\t\t_card.Tags.SetFrom( GameObject.Tags );\r\n\t\t_card.RenderingEnabled = false;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Keep <see cref=\"FlatpackSystem\"/>'s count of visible cards per impostor current, so it streams their atlases in.\r\n\t/// </summary>\r\n\tvoid CountCard( FlatpackImpostor visibleImpostor )\r\n\t{\r\n\t\tif ( visibleImpostor == _countedImpostor )\r\n\t\t\treturn;\r\n\r\n\t\tvar system = Scene?.GetSystem<FlatpackSystem>();\r\n\t\tsystem?.CardVisibilityChanged( _countedImpostor, false );\r\n\t\tsystem?.CardVisibilityChanged( visibleImpostor, true );\r\n\t\t_countedImpostor = visibleImpostor;\r\n\t}\r\n\r\n\tvoid DestroyCard()\r\n\t{\r\n\t\tCountCard( null );\r\n\t\t_card?.Delete();\r\n\t\t_card = null;\r\n\t}\r\n\r\n\tvoid SyncTransform()\r\n\t{\r\n\t\tif ( _card.IsValid() )\r\n\t\t\t_card.Transform = WorldTransform;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Editor/FlatpackBakeSettings.cs",
            "FileName": "FlatpackBakeSettings.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using Sandbox;\n\nnamespace Flatpack.EditorTools;\n\n/// <summary>\n/// Atlas sizes the baker offers.\n/// </summary>\npublic enum FlatpackAtlasSize\n{\n\t[Title( \"512\" )] Size512 = 512,\n\t[Title( \"1024\" )] Size1024 = 1024,\n\t[Title( \"2048\" )] Size2048 = 2048,\n\t[Title( \"3072\" )] Size3072 = 3072,\n\t[Title( \"4096\" )] Size4096 = 4096,\n}\n\n/// <summary>\n/// Quality levels for a bake. Each one sets the number of views, the atlas size and the depth search steps, picked\n/// from the iteration 4 measurements (see <c>Benchmarks/iteration-4-quality-levels</c>).\n/// </summary>\npublic enum FlatpackQuality\n{\n\t[Title( \"Low\" ), Description( \"12 x 12 views, 1024 atlas, 8 search steps. About 5 MB. For props you only see from far away (past about 16 bounding radii).\" )]\n\tLow,\n\n\t[Title( \"Medium\" ), Description( \"12 x 12 views, 2048 atlas, 16 search steps. About 19 MB. The default: holds up from about 4 to 6 bounding radii.\" )]\n\tMedium,\n\n\t[Title( \"High\" ), Description( \"24 x 24 views, 4096 atlas, 16 search steps. About 75 MB. Same as Medium up close, closer to the real model past 8 bounding radii.\" )]\n\tHigh,\n\n\t[Title( \"Ultra\" ), Description( \"12 x 12 views, 3072 atlas, 32 search steps. About 42 MB and about 1.5x the GPU cost of Medium. The sharpest level up close: for props that swap near the camera.\" )]\n\tUltra,\n\n\t[Title( \"Auto\" ), Description( \"Bake at Low, measure the card against the model at the target distance, and step up a level until it holds up. Slower to bake; saves memory on models that look fine at a lower level.\" )]\n\tAuto,\n\n\t[Title( \"Custom\" ), Description( \"Set views, atlas size and search steps yourself.\" )]\n\tCustom,\n}\n\n/// <summary>\n/// Everything the baker needs to know besides the model. Shown in the bake window with a control sheet.\n/// </summary>\npublic sealed class FlatpackBakeSettings\n{\n\t/// <summary>\n\t/// How good the impostor looks, against memory and GPU cost. Pick Custom to set the numbers yourself.\n\t/// </summary>\n\t[Property] public FlatpackQuality Quality { get; set; } = FlatpackQuality.Medium;\n\n\t/// <summary>\n\t/// For Auto: the distance, in bounding radii, where the card has to match the model (from there outward). Set it to\n\t/// the swap distance you'll use; the Flatpack Renderer's automatic one is 30.\n\t/// </summary>\n\t[Property, Range( 2, 60 ), Step( 1 ), ShowIf( nameof( Quality ), FlatpackQuality.Auto )] public float AutoTargetRadii { get; set; } = 30.0f;\n\n\t/// <summary>\n\t/// Measure the finished bake against the model (a second or two) and store where it holds up. The Flatpack Renderer\n\t/// then never swaps closer than that, and the inspector shows it.\n\t/// </summary>\n\t[Property] public bool MeasureQuality { get; set; } = true;\n\n\t/// <summary>\n\t/// Frames per atlas side. 12 gives 144 views, 15 degrees apart on the hemisphere. 8 ghosts visibly; past 12, more\n\t/// views only help a little unless the atlas grows with them. Used when Quality is Custom.\n\t/// </summary>\n\t[Property, Range( 4, 32 ), Step( 1 ), ShowIf( nameof( Quality ), FlatpackQuality.Custom )] public int GridSize { get; set; } = 12;\n\n\t/// <summary>\n\t/// Atlas width and height. Each frame gets AtlasSize / GridSize pixels. Used when Quality is Custom.\n\t/// </summary>\n\t[Property, ShowIf( nameof( Quality ), FlatpackQuality.Custom )] public FlatpackAtlasSize AtlasSize { get; set; } = FlatpackAtlasSize.Size2048;\n\n\t/// <summary>\n\t/// Depth search steps per frame, written into the material (you can change it there later). The biggest single\n\t/// lever on both quality and GPU cost: 8 ghosts thin parts, 32 costs about 1.5x of 16. Used when Quality is Custom.\n\t/// </summary>\n\t[Property, Range( 0, 32 ), Step( 1 ), ShowIf( nameof( Quality ), FlatpackQuality.Custom )] public int SearchSteps { get; set; } = 16;\n\n\t/// <summary>\n\t/// The views, atlas size and search steps this bake will use: the quality level's, or the custom values.\n\t/// </summary>\n\tpublic (int GridSize, FlatpackAtlasSize AtlasSize, int SearchSteps) Resolve() => Quality switch\n\t{\n\t\tFlatpackQuality.Low => (12, FlatpackAtlasSize.Size1024, 8),\n\t\tFlatpackQuality.Medium => (12, FlatpackAtlasSize.Size2048, 16),\n\t\tFlatpackQuality.High => (24, FlatpackAtlasSize.Size4096, 16),\n\t\tFlatpackQuality.Ultra => (12, FlatpackAtlasSize.Size3072, 32),\n\t\tFlatpackQuality.Auto => (12, FlatpackAtlasSize.Size1024, 8),\n\t\t_ => (GridSize, AtlasSize, SearchSteps),\n\t};\n\n\t/// <summary>\n\t/// A copy of these settings at another quality level.\n\t/// </summary>\n\tpublic FlatpackBakeSettings WithQuality( FlatpackQuality quality )\n\t{\n\t\tvar copy = (FlatpackBakeSettings)MemberwiseClone();\n\t\tcopy.Quality = quality;\n\t\treturn copy;\n\t}\n\n\t/// <summary>\n\t/// Which view directions get frames.\n\t/// </summary>\n\t[Property] public FlatpackLayout Layout { get; set; } = FlatpackLayout.Hemisphere;\n\n\t/// <summary>\n\t/// Turn the card around the object's up axis only, instead of facing the camera fully.\n\t/// Always on for <see cref=\"FlatpackLayout.Cylinder\"/>.\n\t/// </summary>\n\t[Property] public bool ZLockedCard { get; set; }\n\n\t/// <summary>\n\t/// Also bake roughness and ambient occlusion. Without it the impostor uses roughness 1 and no AO.\n\t/// </summary>\n\t[Property] public bool BakeRoughnessAo { get; set; } = true;\n\n\t/// <summary>\n\t/// Also bake how much sky each part of the model sees (lit from eight directions, shadowed against unshadowed).\n\t/// The impostor material's Sky Occlusion slider then darkens enclosed parts, which helps dense foliage keep its dark\n\t/// interior. Off by default: the real model gets no such occlusion in-engine, so the card matches it better without.\n\t/// Needs <see cref=\"BakeRoughnessAo\"/>; roughly triples bake time.\n\t/// </summary>\n\t[Property, ShowIf( nameof( BakeRoughnessAo ), true )] public bool BakeSelfShadow { get; set; }\n\n\t/// <summary>\n\t/// Render each view this many times larger and average it down. Parts thinner than a texel (wire mesh, twigs) then\n\t/// bake as partial coverage instead of filling in or vanishing. 2 measured 2 to 3 points fewer wrong pixels up close\n\t/// for about a third more bake time; 4 adds little. Fine wire mesh (fences) gets worse either way.\n\t/// </summary>\n\t[Property, Range( 1, 4 ), Step( 1 )] public int Supersample { get; set; } = 2;\n\n\t/// <summary>\n\t/// How many texels to grow colour out past the silhouette, inside each frame, so mipmaps don't pull in\n\t/// the transparent background as a dark halo. 0 fills the whole frame.\n\t/// </summary>\n\t[Property, Range( 0, 64 ), Step( 1 )] public int EdgePadding { get; set; } = 16;\n\n\t/// <summary>\n\t/// Frame every view to the model's own outline and depth in that view instead of its whole bounding sphere. A tall\n\t/// statue fills its views instead of a third of them, and the depth spans only the surfaces the view sees, so the\n\t/// same atlas holds more texels and finer depth on the object.\n\t/// </summary>\n\t[Property] public bool FitEachView { get; set; } = true;\n\n\t/// <summary>\n\t/// Bits of depth per texel: 8 (BC4, half a byte a texel) or 16 (R16, 2 bytes). Measured in iteration 8 on the test set\n\t/// at Medium: 16 bits had 2.3 points fewer wrong pixels at 2 radii and 1.6 at 4, for more video memory (about\n\t/// 27 MB against 19 at Medium, 60 against 42 at Ultra).\n\t/// </summary>\n\t[Property, Range( 8, 16 ), Step( 8 )] public int DepthBits { get; set; } = 8;\n\n\t/// <summary>\n\t/// Folder for the outputs, relative to the project's Assets folder (for example <c>flatpack/trees</c>).\n\t/// Empty puts them next to the source model, or in <c>flatpack/</c> when the source isn't in this project.\n\t/// </summary>\n\t[Property] public string OutputFolder { get; set; } = \"\";\n}\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Code/FlatpackMath.cs",
            "FileName": "FlatpackMath.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\n\r\nnamespace Flatpack;\r\n\r\n/// <summary>\r\n/// How the baked views (\"frames\") are spread over the directions an impostor can be seen from.\r\n/// </summary>\r\npublic enum FlatpackLayout\r\n{\r\n\t/// <summary>\r\n\t/// Hemi-octahedral: the upper half of the sphere. Best for anything that stands on the ground and is never\r\n\t/// seen from below (trees, rocks, props). All N x N frames go to the half you actually see.\r\n\t/// </summary>\r\n\tHemisphere = 0,\r\n\r\n\t/// <summary>\r\n\t/// Full octahedral: every direction. For floating or tumbling objects that can be seen from below.\r\n\t/// </summary>\r\n\tSphere = 1,\r\n\r\n\t/// <summary>\r\n\t/// All N x N frames on the horizon, evenly around Z, and the card only turns around Z. Highest yaw\r\n\t/// resolution, but it can't show the top of the object. Classic tree billboard behaviour.\r\n\t/// </summary>\r\n\tCylinder = 2,\r\n}\r\n\r\n/// <summary>\r\n/// The frame layout maths shared by the baker and the runtime. <c>flatpack_impostor.shader</c> has an HLSL\r\n/// copy of the same functions; they must stay identical or the shader samples the wrong frame.\r\n/// </summary>\r\n/// <remarks>\r\n/// Coordinates are Source/s&amp;box: +X forward, +Y left, +Z up. A frame direction points from the object\r\n/// toward the camera that captured it.\r\n/// </remarks>\r\npublic static class FlatpackMath\r\n{\r\n\t/// <summary>\r\n\t/// Maps a unit direction to the [-1, 1] square for the given layout (not used by <see cref=\"FlatpackLayout.Cylinder\"/>).\r\n\t/// </summary>\r\n\tpublic static Vector2 EncodeOct( Vector3 d, FlatpackLayout layout )\r\n\t{\r\n\t\tif ( layout == FlatpackLayout.Hemisphere )\r\n\t\t{\r\n\t\t\t// Nothing below the horizon was baked, so clamp onto the horizon ring.\r\n\t\t\td.z = MathF.Max( d.z, 0.0f );\r\n\t\t\td = (d + new Vector3( 0, 0, 1e-5f )).Normal;\r\n\t\t\tvar px = d.x / (MathF.Abs( d.x ) + MathF.Abs( d.y ) + d.z);\r\n\t\t\tvar py = d.y / (MathF.Abs( d.x ) + MathF.Abs( d.y ) + d.z);\r\n\r\n\t\t\t// Rotate the octahedron's top half by 45 degrees so it fills the whole square.\r\n\t\t\treturn new Vector2( px + py, px - py );\r\n\t\t}\r\n\r\n\t\tvar sum = MathF.Abs( d.x ) + MathF.Abs( d.y ) + MathF.Abs( d.z );\r\n\t\tvar p = new Vector2( d.x / sum, d.y / sum );\r\n\t\tif ( d.z < 0.0f )\r\n\t\t{\r\n\t\t\t// Fold the lower half out into the square's corners.\r\n\t\t\tp = new Vector2( (1.0f - MathF.Abs( p.y )) * Sign( p.x ), (1.0f - MathF.Abs( p.x )) * Sign( p.y ) );\r\n\t\t}\r\n\r\n\t\treturn p;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Inverse of <see cref=\"EncodeOct\"/>. Returns a unit direction.\r\n\t/// </summary>\r\n\tpublic static Vector3 DecodeOct( Vector2 o, FlatpackLayout layout )\r\n\t{\r\n\t\tif ( layout == FlatpackLayout.Hemisphere )\r\n\t\t{\r\n\t\t\tvar px = (o.x + o.y) * 0.5f;\r\n\t\t\tvar py = (o.x - o.y) * 0.5f;\r\n\t\t\treturn new Vector3( px, py, 1.0f - MathF.Abs( px ) - MathF.Abs( py ) ).Normal;\r\n\t\t}\r\n\r\n\t\tvar d = new Vector3( o.x, o.y, 1.0f - MathF.Abs( o.x ) - MathF.Abs( o.y ) );\r\n\t\tif ( d.z < 0.0f )\r\n\t\t{\r\n\t\t\td.x = (1.0f - MathF.Abs( o.y )) * Sign( o.x );\r\n\t\t\td.y = (1.0f - MathF.Abs( o.x )) * Sign( o.y );\r\n\t\t}\r\n\r\n\t\treturn d.Normal;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The direction frame (<paramref name=\"x\"/>, <paramref name=\"y\"/>) of an <paramref name=\"n\"/> x <paramref name=\"n\"/> grid is baked from.\r\n\t/// </summary>\r\n\tpublic static Vector3 FrameDirection( int x, int y, int n, FlatpackLayout layout )\r\n\t{\r\n\t\tif ( layout == FlatpackLayout.Cylinder )\r\n\t\t{\r\n\t\t\tvar index = y * n + x;\r\n\t\t\tvar yaw = index / (float)(n * n) * MathF.Tau;\r\n\t\t\treturn new Vector3( MathF.Cos( yaw ), MathF.Sin( yaw ), 0.0f );\r\n\t\t}\r\n\r\n\t\t// Frame centres sit on the grid vertices (not cell centres), so the outer frames land exactly on the\r\n\t\t// horizon and the shader can blend between them without extrapolating.\r\n\t\tvar o = new Vector2( x, y ) / (n - 1) * 2.0f - 1.0f;\r\n\t\treturn DecodeOct( o, layout );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The camera basis a frame is captured with. Right is image +X, up is image -Y.\r\n\t/// </summary>\r\n\tpublic static void FrameBasis( Vector3 d, out Vector3 right, out Vector3 up )\r\n\t{\r\n\t\tvar refUp = MathF.Abs( d.z ) < 0.999f ? Vector3.Up : Vector3.Forward;\r\n\t\tright = Vector3.Cross( -d, refUp ).Normal;\r\n\t\tup = Vector3.Cross( right, -d );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Camera rotation for a frame. Equivalent to <see cref=\"FrameBasis\"/> (checked in the editor: <c>Rotation.LookAt</c>\r\n\t/// produces the same right and up vectors).\r\n\t/// </summary>\r\n\tpublic static Rotation FrameRotation( Vector3 d )\r\n\t{\r\n\t\tFrameBasis( d, out _, out var up );\r\n\t\treturn Rotation.LookAt( -d, up );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Smallest sphere-ish bound we bake around: the box centre and its half diagonal. Not the tightest sphere,\r\n\t/// but stable and cheap, and the shader only needs the frames to contain the object.\r\n\t/// </summary>\r\n\tpublic static (Vector3 Center, float Radius) BoundingSphere( BBox bounds )\r\n\t{\r\n\t\treturn (bounds.Center, MathF.Max( bounds.Size.Length * 0.5f, 0.01f ));\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// A bounding sphere fitted to points (Ritter's method): pick two far-apart points, then grow the sphere for any point\r\n\t/// left outside. Within a few percent of the minimal sphere and never smaller than needed.\r\n\t/// </summary>\r\n\tpublic static (Vector3 Center, float Radius) BoundingSphere( IReadOnlyList<Vector3> points )\r\n\t{\r\n\t\tif ( points is null || points.Count == 0 )\r\n\t\t\treturn (Vector3.Zero, 0.01f);\r\n\r\n\t\tvar a = points[0];\r\n\t\tvar b = Farthest( points, a );\r\n\t\tvar c = Farthest( points, b );\r\n\r\n\t\tvar center = (b + c) * 0.5f;\r\n\t\tvar radius = b.Distance( c ) * 0.5f;\r\n\r\n\t\tforeach ( var p in points )\r\n\t\t{\r\n\t\t\tvar d = p.Distance( center );\r\n\t\t\tif ( d <= radius )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\t// Move the centre toward p just enough that the new sphere still touches the old one's far side.\r\n\t\t\tvar newRadius = (radius + d) * 0.5f;\r\n\t\t\tcenter += (p - center) * ((newRadius - radius) / d);\r\n\t\t\tradius = newRadius;\r\n\t\t}\r\n\r\n\t\t// A hair of slack so silhouette texels on the sphere's rim aren't clipped by the frame edge.\r\n\t\treturn (center, MathF.Max( radius * 1.01f, 0.01f ));\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Metres in one world unit. s&amp;box units are inches, as in Source; the iteration 6 distance sheet (\"6 radii (21 m)\"\r\n\t/// for a tree 140 units in radius) used the same conversion.\r\n\t/// </summary>\r\n\tpublic const float MetresPerUnit = 0.0254f;\r\n\r\n\t/// <summary>A distance in world units, in metres.</summary>\r\n\tpublic static float ToMetres( float units ) => units * MetresPerUnit;\r\n\r\n\t/// <summary>\r\n\t/// A distance in world units as a reader wants it next to bounding radii: one decimal under 10 m, whole metres from\r\n\t/// there (\"2.5 m\", \"21 m\"). The inspector, the bake check summary and the benchmark sheets all use this.\r\n\t/// </summary>\r\n\tpublic static string FormatMetres( float units )\r\n\t{\r\n\t\tvar metres = ToMetres( units );\r\n\t\treturn metres < 10.0f\r\n\t\t\t? $\"{metres.ToString( \"0.0\", System.Globalization.CultureInfo.InvariantCulture )} m\"\r\n\t\t\t: $\"{metres.ToString( \"0\", System.Globalization.CultureInfo.InvariantCulture )} m\";\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Columns the frame lookup texture reserves for image rectangles; the depth ranges sit this far to the right.\r\n\t/// The shader's <c>FlatpackFrameFit</c> reads texel (x, y) and (x + 32, y).\r\n\t/// </summary>\r\n\tpublic const int FrameFitColumns = 32;\r\n\r\n\t/// <summary>\r\n\t/// The bounds one frame was baked with: where the object sits inside the bounding sphere's frame, and how deep.\r\n\t/// </summary>\r\n\tpublic struct FrameFit\r\n\t{\r\n\t\t/// <summary>Top-left corner of the frame's image rectangle, in the sphere-sized frame's 0..1 image coordinates (v down).</summary>\r\n\t\tpublic Vector2 Min;\r\n\r\n\t\t/// <summary>Bottom-right corner of the image rectangle.</summary>\r\n\t\tpublic Vector2 Max;\r\n\r\n\t\t/// <summary>Height of the nearest surface along the frame direction (toward its camera), in bounding radii.</summary>\r\n\t\tpublic float Near;\r\n\r\n\t\t/// <summary>Height of the furthest surface, in bounding radii. Always below <see cref=\"Near\"/>.</summary>\r\n\t\tpublic float Far;\r\n\r\n\t\t/// <summary>The whole bounding sphere: what every frame used before iteration 8.</summary>\r\n\t\tpublic static FrameFit Sphere => new() { Min = Vector2.Zero, Max = Vector2.One, Near = 1.0f, Far = -1.0f };\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Fit one frame to the points it sees: their image rectangle, grown by <paramref name=\"padTexels\"/> of a\r\n\t/// <paramref name=\"cellTexels\"/>-wide frame on every side, and their depth range. Rounded outward to the 8-bit steps the\r\n\t/// lookup texture stores, so the baker renders with exactly the bounds the shader reads back.\r\n\t/// </summary>\r\n\tpublic static FrameFit FitFrame( IReadOnlyList<Vector3> points, Vector3 center, float radius, Vector3 dir, int cellTexels, int padTexels )\r\n\t{\r\n\t\tif ( points is null || points.Count == 0 )\r\n\t\t\treturn FrameFit.Sphere;\r\n\r\n\t\tFrameBasis( dir, out var right, out var up );\r\n\t\tfloat minU = float.MaxValue, maxU = float.MinValue, minV = float.MaxValue, maxV = float.MinValue;\r\n\t\tfloat near = float.MinValue, far = float.MaxValue;\r\n\r\n\t\tforeach ( var p in points )\r\n\t\t{\r\n\t\t\tvar rel = p - center;\r\n\t\t\tvar u = 0.5f + Vector3.Dot( rel, right ) / (2.0f * radius);\r\n\t\t\tvar v = 0.5f - Vector3.Dot( rel, up ) / (2.0f * radius);\r\n\t\t\tvar h = Vector3.Dot( rel, dir ) / radius;\r\n\t\t\tminU = MathF.Min( minU, u ); maxU = MathF.Max( maxU, u );\r\n\t\t\tminV = MathF.Min( minV, v ); maxV = MathF.Max( maxV, v );\r\n\t\t\tnear = MathF.Max( near, h ); far = MathF.Min( far, h );\r\n\t\t}\r\n\r\n\t\t// Padding: the rectangle maps onto cellTexels texels, padTexels of which on each side stay outside the object.\r\n\t\tvar grow = cellTexels > 2 * padTexels + 1 ? cellTexels / (float)(cellTexels - 2 * padTexels) : 1.0f;\r\n\t\tvar padU = (maxU - minU) * (grow - 1.0f) * 0.5f;\r\n\t\tvar padV = (maxV - minV) * (grow - 1.0f) * 0.5f;\r\n\r\n\t\tvar fit = new FrameFit\r\n\t\t{\r\n\t\t\tMin = new Vector2( minU - padU, minV - padV ),\r\n\t\t\tMax = new Vector2( maxU + padU, maxV + padV ),\r\n\t\t\tNear = near,\r\n\t\t\tFar = far,\r\n\t\t};\r\n\t\treturn DecodeFrameFit( EncodeFrameFit( fit ) );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The two lookup texels for a frame: (left, top, right) and (bottom, near, far), each value one byte, rounded outward\r\n\t/// so the stored bounds always contain the real ones. Alpha stays 255 (the PNG path drops colour under low alpha).\r\n\t/// </summary>\r\n\tpublic static (Color32 Rect, Color32 Depth) EncodeFrameFit( FrameFit fit )\r\n\t{\r\n\t\tint Down( float v ) => Math.Clamp( (int)MathF.Floor( v * 255.0f + 1e-3f ), 0, 255 );\r\n\t\tint Up( float v ) => Math.Clamp( (int)MathF.Ceiling( v * 255.0f - 1e-3f ), 0, 255 );\r\n\r\n\t\tint minU = Down( fit.Min.x ), maxU = Up( fit.Max.x );\r\n\t\tint minV = Down( fit.Min.y ), maxV = Up( fit.Max.y );\r\n\t\tint near = Up( (fit.Near + 1.0f) * 0.5f ), far = Down( (fit.Far + 1.0f) * 0.5f );\r\n\r\n\t\t// Never let a frame collapse (a flat panel seen edge-on, a plane seen face-on): keep at least two steps each way.\r\n\t\t(minU, maxU) = Widen( minU, maxU );\r\n\t\t(minV, maxV) = Widen( minV, maxV );\r\n\t\t(far, near) = Widen( far, near );\r\n\r\n\t\treturn (new Color32( (byte)minU, (byte)minV, (byte)maxU, 255 ), new Color32( (byte)maxV, (byte)near, (byte)far, 255 ));\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The bounds two lookup texels describe. Matches the shader's <c>FlatpackFrameFit</c>.\r\n\t/// </summary>\r\n\tpublic static FrameFit DecodeFrameFit( (Color32 Rect, Color32 Depth) texels ) => new()\r\n\t{\r\n\t\tMin = new Vector2( texels.Rect.r / 255.0f, texels.Rect.g / 255.0f ),\r\n\t\tMax = new Vector2( texels.Rect.b / 255.0f, texels.Depth.r / 255.0f ),\r\n\t\tNear = texels.Depth.g / 255.0f * 2.0f - 1.0f,\r\n\t\tFar = texels.Depth.b / 255.0f * 2.0f - 1.0f,\r\n\t};\r\n\r\n\tstatic (int Low, int High) Widen( int low, int high )\r\n\t{\r\n\t\twhile ( high - low < 2 )\r\n\t\t{\r\n\t\t\tif ( high < 255 ) high++;\r\n\t\t\tif ( high - low < 2 && low > 0 ) low--;\r\n\t\t}\r\n\t\treturn (low, high);\r\n\t}\r\n\r\n\tstatic Vector3 Farthest( IReadOnlyList<Vector3> points, Vector3 from )\r\n\t{\r\n\t\tvar best = from;\r\n\t\tvar bestDistance = -1.0f;\r\n\t\tforeach ( var p in points )\r\n\t\t{\r\n\t\t\tvar d = p.DistanceSquared( from );\r\n\t\t\tif ( d > bestDistance )\r\n\t\t\t{\r\n\t\t\t\tbestDistance = d;\r\n\t\t\t\tbest = p;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn best;\r\n\t}\r\n\r\n\tstatic float Sign( float v ) => v >= 0.0f ? 1.0f : -1.0f;\r\n}\r\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "Editor/FlatpackSceneOptimizer.cs",
            "FileName": "FlatpackSceneOptimizer.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Threading;\r\nusing Editor;\r\nusing Sandbox;\r\n\r\nnamespace Flatpack.EditorTools;\r\n\r\n/// <summary>\r\n/// One window to Flatpack a whole scene: lists every model in it, recommends which ones are worth an impostor and why,\r\n/// bakes what's missing and adds a <see cref=\"FlatpackRenderer\"/> to every instance in one undoable step.\r\n/// Open it from Editor > Flatpack > Optimize Scene.\r\n/// </summary>\r\npublic sealed class FlatpackSceneOptimizer : Dialog\r\n{\r\n\t/// <summary>\r\n\t/// How strongly the window suggests Flatpacking a model.\r\n\t/// </summary>\r\n\tpublic enum Advice\r\n\t{\r\n\t\tRecommended,\r\n\t\tLowGain,\r\n\t\tSkip,\r\n\t\tDone,\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Everything the window knows about one model used in the scene.\r\n\t/// </summary>\r\n\tpublic sealed class Entry\r\n\t{\r\n\t\tpublic Model Model { get; init; }\r\n\t\tpublic List<ModelRenderer> Renderers { get; } = new();\r\n\t\tpublic int Triangles { get; init; }\r\n\t\tpublic int Flatpacked { get; set; }\r\n\t\tpublic FlatpackImpostor Impostor { get; set; }\r\n\t\tpublic Advice Advice { get; set; }\r\n\t\tpublic string Reason { get; set; }\r\n\t\tpublic bool Selected { get; set; }\r\n\r\n\t\tpublic int Instances => Renderers.Count;\r\n\t\tpublic long TotalTriangles => (long)Triangles * Instances;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Below this many triangles a card rarely beats the model: the card's pixel shader costs about the same as a cheap\r\n\t/// mesh (see Benchmarks/iteration-2: a 1,832-triangle plant broke even).\r\n\t/// </summary>\r\n\tpublic const int MinTriangles = 1500;\r\n\r\n\t/// <summary>\r\n\t/// A cheap model can still be worth it in bulk: this many triangles across all its instances.\r\n\t/// </summary>\r\n\tpublic const long MinTotalTriangles = 200_000;\r\n\r\n\t/// <summary>\r\n\t/// Above this per-mip alpha correction at mip 2, a bake has a lot of thin detail (twigs, wire mesh) that the card\r\n\t/// keeps only from further away. The model is still recommended, with a warning.\r\n\t/// </summary>\r\n\tpublic const float ThinDetailWarning = 1.25f;\r\n\r\n\tstatic FlatpackSceneOptimizer _current;\r\n\r\n\treadonly List<Entry> _entries = new();\r\n\treadonly ScrollArea _list;\r\n\treadonly Label _summary;\r\n\treadonly Label _status;\r\n\treadonly Button _apply;\r\n\treadonly Button _remove;\r\n\tCancellationTokenSource _cancel;\r\n\tScene _scannedScene;\r\n\r\n\t[Menu( \"Editor\", \"Flatpack/Optimize Scene\" )]\r\n\tpublic static void Open()\r\n\t{\r\n\t\tif ( _current is not { IsValid: true } )\r\n\t\t\t_current = new FlatpackSceneOptimizer();\r\n\r\n\t\t_current.Scan();\r\n\t\t_current.Show();\r\n\t}\r\n\r\n\tFlatpackSceneOptimizer() : base( EditorWindow )\r\n\t{\r\n\t\tWindow.Title = \"Flatpack: Optimize Scene\";\r\n\t\tWindow.SetWindowIcon( \"forest\" );\r\n\t\tWindow.Size = new Vector2( 860, 620 );\r\n\t\tWindow.StateCookie = \"FlatpackSceneOptimizer\";\r\n\r\n\t\tLayout = Layout.Column();\r\n\t\tLayout.Margin = 12;\r\n\t\tLayout.Spacing = 8;\r\n\r\n\t\tvar title = Layout.Add( new Label( \"Every model in the open scene, with a recommendation for each.\" ) );\r\n\t\ttitle.SetStyles( \"font-weight: bold;\" );\r\n\t\tLayout.Add( new Label( \"Ticked models get baked (if needed) and a Flatpack Renderer on every instance, as one step you can undo with Ctrl+Z.\" ) { WordWrap = true } );\r\n\r\n\t\tvar tools = Layout.AddRow();\r\n\t\ttools.Spacing = 6;\r\n\t\ttools.Add( new Button( \"Select Recommended\", \"auto_awesome\" ) { Clicked = () => SelectWhere( e => e.Advice == Advice.Recommended ) } );\r\n\t\ttools.Add( new Button( \"Select All\", \"select_all\" ) { Clicked = () => SelectWhere( e => e.Advice != Advice.Done && e.Advice != Advice.Skip ) } );\r\n\t\ttools.Add( new Button( \"Select None\", \"deselect\" ) { Clicked = () => SelectWhere( _ => false ) } );\r\n\t\ttools.AddStretchCell();\r\n\t\ttools.Add( new Button( \"Rescan\", \"refresh\" ) { Clicked = Scan } );\r\n\r\n\t\t_list = Layout.Add( new ScrollArea( this ), 1 );\r\n\t\t_list.Canvas = new Widget( _list );\r\n\t\t_list.Canvas.Layout = Layout.Column();\r\n\t\t_list.Canvas.Layout.Spacing = 2;\r\n\r\n\t\t_summary = Layout.Add( new Label( \"\" ) { WordWrap = true } );\r\n\t\t_status = Layout.Add( new Label( \"\" ) { WordWrap = true } );\r\n\r\n\t\t// Quality for the bakes this window makes. Shares the bake window's saved settings, so both stay in step.\r\n\t\tvar settings = EditorCookie.Get( \"Flatpack.BakeSettings\", new FlatpackBakeSettings() ) ?? new FlatpackBakeSettings();\r\n\t\tvar serializedSettings = settings.GetSerialized();\r\n\t\tserializedSettings.OnPropertyChanged += _ =>\r\n\t\t{\r\n\t\t\tEditorCookie.Set( \"Flatpack.BakeSettings\", settings );\r\n\t\t\tUpdateSummary();\r\n\t\t};\r\n\t\tvar quality = new ControlSheet();\r\n\t\tquality.AddObject( serializedSettings, p => p.Name == nameof( FlatpackBakeSettings.Quality ) );\r\n\t\tLayout.Add( quality );\r\n\r\n\t\tvar footer = Layout.AddRow();\r\n\t\tfooter.Spacing = 8;\r\n\t\t_remove = footer.Add( new Button( \"Remove Flatpack from Scene\", \"undo\" )\r\n\t\t{\r\n\t\t\tToolTip = \"Remove every Flatpack Renderer in the open scene, as one undoable step. Baked files stay.\",\r\n\t\t\tClicked = RemoveAll,\r\n\t\t} );\r\n\t\tfooter.AddStretchCell();\r\n\t\t_apply = footer.Add( new Button.Primary( \"Flatpack Selected\", \"view_in_ar\" ) { Clicked = OnApplyClicked } );\r\n\t}\r\n\r\n\tpublic override void OnDestroyed()\r\n\t{\r\n\t\t_cancel?.Cancel();\r\n\t\tif ( _current == this )\r\n\t\t\t_current = null;\r\n\r\n\t\tbase.OnDestroyed();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Gather every model in the open scene and decide what to recommend for each.\r\n\t/// </summary>\r\n\tpublic void Scan()\r\n\t{\r\n\t\t// Keep the user's ticks across rescans (the scene may have changed under us, e.g. after Ctrl+Z).\r\n\t\tvar scene = SceneEditorSession.Active?.Scene;\r\n\t\tvar previous = scene == _scannedScene\r\n\t\t\t? _entries.Where( e => e.Advice != Advice.Done && e.Advice != Advice.Skip ).ToDictionary( e => e.Model, e => e.Selected )\r\n\t\t\t: new Dictionary<Model, bool>();\r\n\t\t_scannedScene = scene;\r\n\t\t_entries.Clear();\r\n\t\tif ( scene is null )\r\n\t\t{\r\n\t\t\tRebuild();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tforeach ( var renderer in scene.GetAllComponents<ModelRenderer>() )\r\n\t\t{\r\n\t\t\tvar model = renderer.Model;\r\n\t\t\tif ( model is null || model.IsError )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar entry = _entries.FirstOrDefault( e => e.Model == model );\r\n\t\t\tif ( entry is null )\r\n\t\t\t{\r\n\t\t\t\tentry = new Entry { Model = model, Triangles = Lod0Triangles( model ) };\r\n\t\t\t\t_entries.Add( entry );\r\n\t\t\t}\r\n\r\n\t\t\tentry.Renderers.Add( renderer );\r\n\t\t\tif ( renderer.Components.Get<FlatpackRenderer>().IsValid() )\r\n\t\t\t\tentry.Flatpacked++;\r\n\t\t}\r\n\r\n\t\tforeach ( var entry in _entries )\r\n\t\t{\r\n\t\t\tentry.Impostor = FlatpackRenderer.FindImpostorFor( entry.Model );\r\n\t\t\tRecommend( entry );\r\n\t\t\tentry.Selected = entry.Advice != Advice.Done && entry.Advice != Advice.Skip\r\n\t\t\t\t&& ( previous.TryGetValue( entry.Model, out var was ) ? was : entry.Advice == Advice.Recommended );\r\n\t\t}\r\n\r\n\t\t// Biggest wins first.\r\n\t\t_entries.Sort( ( a, b ) => b.TotalTriangles.CompareTo( a.TotalTriangles ) );\r\n\t\tRebuild();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The recommendation rules. Kept in one place so the reasons in the list always match the decision.\r\n\t/// </summary>\r\n\t/// <summary>\r\n\t/// Typical time to bake one model (including the quality check after it) and the video memory its atlases use, by\r\n\t/// quality level. Measured on an RTX 3090 in iterations 4 to 6; Auto bakes Low first and climbs, so it's a range.\r\n\t/// </summary>\r\n\tstatic (float Seconds, float MaxSeconds, float Megabytes) BakeCost( FlatpackBakeSettings settings )\r\n\t{\r\n\t\t// Supersampling renders each view larger; 2 is the default the times below were measured at.\r\n\t\tvar ss = Math.Clamp( settings.Supersample, 1, 4 );\r\n\t\tvar time = ss switch { 1 => 0.75f, 2 => 1.0f, 3 => 1.3f, _ => 1.7f };\r\n\t\tif ( settings.BakeSelfShadow )\r\n\t\t\ttime *= 3.0f;\r\n\r\n\t\treturn settings.Quality switch\r\n\t\t{\r\n\t\t\tFlatpackQuality.Low => (3 * time, 3 * time, 4),\r\n\t\t\tFlatpackQuality.Medium => (6 * time, 6 * time, 16),\r\n\t\t\tFlatpackQuality.High => (16 * time, 16 * time, 64),\r\n\t\t\tFlatpackQuality.Ultra => (12 * time, 12 * time, 36),\r\n\t\t\tFlatpackQuality.Auto => (4 * time, 40 * time, 16),\r\n\t\t\t_ => CustomCost( settings, time ),\r\n\t\t};\r\n\t}\r\n\r\n\tstatic (float, float, float) CustomCost( FlatpackBakeSettings settings, float time )\r\n\t{\r\n\t\tvar (grid, atlas, _) = settings.Resolve();\r\n\t\tvar size = (float)(int)atlas;\r\n\t\tvar megabytes = size * size * 3 * 4 / 3 / (1024 * 1024);\r\n\t\tvar seconds = (2.0f + 4.0f * (size / 2048) * (size / 2048) * MathF.Max( 1, grid / 12.0f )) * time;\r\n\t\treturn (seconds, seconds, megabytes);\r\n\t}\r\n\r\n\tstatic string Duration( float seconds ) => seconds < 90 ? $\"{seconds:0} s\" : $\"{seconds / 60:0.#} min\";\r\n\r\n\t/// <summary>\r\n\t/// Triangles in the model's most detailed LOD, every mesh and draw call. <c>Model.GetIndexCountForLod</c> counts only\r\n\t/// the first draw call, which undercounts models with several materials (a tree's bark and leaves: 293,609 of 534,024).\r\n\t/// </summary>\r\n\tpublic static int Lod0Triangles( Model model )\r\n\t{\r\n\t\tvar meshes = model.MeshInfo?.Meshes;\r\n\t\tif ( meshes is null || meshes.Length == 0 )\r\n\t\t\treturn model.GetIndexCountForLod( 0 ) / 3;\r\n\r\n\t\tvar total = meshes.Where( m => ((long)m.LodMask & 1) != 0 ).Sum( m => (long)m.Triangles );\r\n\t\treturn (int)Math.Min( total > 0 ? total : model.GetIndexCountForLod( 0 ) / 3, int.MaxValue );\r\n\t}\r\n\r\n\tstatic void Recommend( Entry e )\r\n\t{\r\n\t\tif ( e.Flatpacked == e.Instances )\r\n\t\t{\r\n\t\t\te.Advice = Advice.Done;\r\n\t\t\te.Reason = \"Already Flatpacked.\";\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( e.Renderers.Any( r => r is SkinnedModelRenderer ) )\r\n\t\t{\r\n\t\t\te.Advice = Advice.Skip;\r\n\t\t\te.Reason = \"Animated: a card can't show animation.\";\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( e.Triangles <= 12 )\r\n\t\t{\r\n\t\t\te.Advice = Advice.Skip;\r\n\t\t\te.Reason = \"Already as simple as a card.\";\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( e.Model.RenderBounds.Size.Length < 8.0f )\r\n\t\t{\r\n\t\t\te.Advice = Advice.Skip;\r\n\t\t\te.Reason = \"Tiny: it's only a few pixels before a card would take over.\";\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( e.Triangles < MinTriangles && e.TotalTriangles < MinTotalTriangles )\r\n\t\t{\r\n\t\t\te.Advice = Advice.LowGain;\r\n\t\t\te.Reason = $\"Cheap already ({e.Triangles:n0} triangles): a card saves little here.\";\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\te.Advice = Advice.Recommended;\r\n\t\te.Reason = e.Triangles >= MinTriangles\r\n\t\t\t? $\"{e.Triangles:n0} triangles each: big saving at a distance.\"\r\n\t\t\t: $\"{e.TotalTriangles:n0} triangles across {e.Instances} copies.\";\r\n\r\n\t\tif ( e.Impostor is not null && e.Impostor.ThinDetail > ThinDetailWarning )\r\n\t\t\te.Reason += \" Has thin parts (twigs, wire): check it holds up at your swap distance.\";\r\n\t}\r\n\r\n\tvoid SelectWhere( Func<Entry, bool> predicate )\r\n\t{\r\n\t\tforeach ( var e in _entries )\r\n\t\t\te.Selected = predicate( e );\r\n\r\n\t\tRebuild();\r\n\t}\r\n\r\n\tvoid Rebuild()\r\n\t{\r\n\t\tif ( !IsValid )\r\n\t\t\treturn;\r\n\r\n\t\t_list.Canvas.Layout.Clear( true );\r\n\r\n\t\tif ( _entries.Count == 0 )\r\n\t\t{\r\n\t\t\t_list.Canvas.Layout.Add( new Label( SceneEditorSession.Active is null ? \"Open a scene first.\" : \"No models in this scene.\" ) );\r\n\t\t}\r\n\r\n\t\tvar header = _list.Canvas.Layout.AddRow();\r\n\t\theader.Spacing = 8;\r\n\t\tforeach ( var (text, width) in new[] { (\"\", 24), (\"Model\", 230), (\"Copies\", 60), (\"Triangles\", 90), (\"Status\", 90), (\"Recommendation\", 330) } )\r\n\t\t{\r\n\t\t\tvar l = header.Add( new Label( text ) { FixedWidth = width } );\r\n\t\t\tl.SetStyles( \"font-weight: bold;\" );\r\n\t\t}\r\n\r\n\t\tforeach ( var entry in _entries )\r\n\t\t{\r\n\t\t\tvar row = _list.Canvas.Layout.AddRow();\r\n\t\t\trow.Spacing = 8;\r\n\r\n\t\t\tvar check = row.Add( new Checkbox( \"\" ) { FixedWidth = 24, Value = entry.Selected } );\r\n\t\t\tcheck.Enabled = entry.Advice != Advice.Done;\r\n\t\t\tcheck.StateChanged = _ => { entry.Selected = check.Value; UpdateSummary(); };\r\n\r\n\t\t\trow.Add( new Label( entry.Model.ResourceName ) { FixedWidth = 230, ToolTip = entry.Model.ResourcePath } );\r\n\t\t\trow.Add( new Label( $\"{entry.Instances}\" ) { FixedWidth = 60 } );\r\n\t\t\trow.Add( new Label( $\"{entry.Triangles:n0}\" ) { FixedWidth = 90 } );\r\n\r\n\t\t\tvar status = entry.Flatpacked == entry.Instances ? \"Done\"\r\n\t\t\t\t: entry.Flatpacked > 0 ? $\"{entry.Flatpacked}/{entry.Instances}\"\r\n\t\t\t\t: entry.Impostor is not null ? \"Baked\" : \"Not baked\";\r\n\t\t\trow.Add( new Label( status ) { FixedWidth = 90 } );\r\n\r\n\t\t\tvar advice = row.Add( new Label( entry.Reason ) { FixedWidth = 330, WordWrap = true } );\r\n\t\t\tadvice.Color = entry.Advice switch\r\n\t\t\t{\r\n\t\t\t\tAdvice.Recommended => Theme.Green,\r\n\t\t\t\tAdvice.LowGain => Theme.Yellow,\r\n\t\t\t\tAdvice.Skip => Theme.TextLight.WithAlpha( 0.6f ),\r\n\t\t\t\t_ => Theme.TextLight,\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\t_list.Canvas.Layout.AddStretchCell();\r\n\t\tUpdateSummary();\r\n\t}\r\n\r\n\tvoid UpdateSummary()\r\n\t{\r\n\t\tvar picked = _entries.Where( e => e.Selected && e.Advice != Advice.Done ).ToArray();\r\n\t\tvar instances = picked.Sum( e => e.Instances - e.Flatpacked );\r\n\t\tvar before = picked.Sum( e => (long)e.Triangles * (e.Instances - e.Flatpacked) );\r\n\t\tvar toBake = picked.Count( e => e.Impostor is null );\r\n\r\n\t\tif ( picked.Length == 0 )\r\n\t\t{\r\n\t\t\t_summary.Text = \"Nothing selected.\";\r\n\t\t\t_apply.Enabled = false;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar settings = EditorCookie.Get( \"Flatpack.BakeSettings\", new FlatpackBakeSettings() ) ?? new FlatpackBakeSettings();\r\n\t\tvar cost = BakeCost( settings );\r\n\t\tvar bakeText = toBake == 0\r\n\t\t\t? \"Everything is baked already.\"\r\n\t\t\t: cost.MaxSeconds > cost.Seconds\r\n\t\t\t\t? $\"{toBake} to bake at {settings.Quality}: about {Duration( toBake * cost.Seconds )} (up to {Duration( toBake * cost.MaxSeconds )}), adding about {toBake * cost.Megabytes:n0} MB of video memory.\"\r\n\t\t\t\t: $\"{toBake} to bake at {settings.Quality}: about {Duration( toBake * cost.Seconds )}, adding about {toBake * cost.Megabytes:n0} MB of video memory.\";\r\n\r\n\t\t_summary.Text = $\"{picked.Length} model(s), {instances:n0} object(s). Far away they draw {instances * 2:n0} triangles instead of {before:n0}. {bakeText} \" +\r\n\t\t\t\"Gains are biggest where you can see far (forests, open maps); in closed rooms the cost is usually lights and draw calls.\";\r\n\r\n\t\t_apply.Enabled = picked.Length > 0 && _cancel is null;\r\n\t}\r\n\r\n\tasync void OnApplyClicked()\r\n\t{\r\n\t\tif ( _cancel is not null )\r\n\t\t{\r\n\t\t\t_cancel.Cancel();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// The scene may have changed since the list was built (edits, Ctrl+Z): start from what's there now.\r\n\t\tScan();\r\n\t\tvar picked = _entries.Where( e => e.Selected && e.Advice != Advice.Done ).ToList();\r\n\t\tif ( picked.Count == 0 )\r\n\t\t\treturn;\r\n\r\n\t\t_cancel = new CancellationTokenSource();\r\n\t\t_apply.Text = \"Cancel\";\r\n\t\t_apply.Enabled = true;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\t// Bake what's missing first; scene changes come after, in one undo step.\r\n\t\t\tvar toBake = picked.Where( e => e.Impostor is null ).ToList();\r\n\t\t\tvar settings = EditorCookie.Get( \"Flatpack.BakeSettings\", new FlatpackBakeSettings() ) ?? new FlatpackBakeSettings();\r\n\r\n\t\t\tfor ( var i = 0; i < toBake.Count; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar entry = toBake[i];\r\n\t\t\t\tvar index = i;\r\n\t\t\t\tvar result = await FlatpackBaker.BakeAsync( entry.Model, settings,\r\n\t\t\t\t\t( f, text ) => { if ( IsValid ) _status.Text = $\"Baking {entry.Model.ResourceName} ({index + 1} of {toBake.Count}): {text}\"; },\r\n\t\t\t\t\t_cancel.Token );\r\n\t\t\t\tentry.Impostor = result.Impostor;\r\n\t\t\t}\r\n\r\n\t\t\tvar added = Apply( picked );\r\n\t\t\t_status.Text = $\"Added a Flatpack Renderer to {added:n0} object(s). Ctrl+Z undoes it, or use Remove Flatpack from Scene.\";\r\n\t\t}\r\n\t\tcatch ( OperationCanceledException )\r\n\t\t{\r\n\t\t\t_status.Text = \"Cancelled. Nothing in the scene changed; finished bakes are kept.\";\r\n\t\t}\r\n\t\tcatch ( Exception ex )\r\n\t\t{\r\n\t\t\t_status.Text = $\"Stopped: {ex.Message}\";\r\n\t\t\tLog.Warning( $\"Flatpack: scene optimise stopped: {ex}\" );\r\n\t\t}\r\n\t\tfinally\r\n\t\t{\r\n\t\t\t_cancel = null;\r\n\t\t\tif ( IsValid )\r\n\t\t\t{\r\n\t\t\t\t_apply.Text = \"Flatpack Selected\";\r\n\t\t\t\tScan();\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Add a Flatpack Renderer to every instance of the picked models that doesn't have one, as a single undo step.\r\n\t/// </summary>\r\n\tstatic int Apply( IEnumerable<Entry> picked )\r\n\t{\r\n\t\tvar session = SceneEditorSession.Active;\r\n\t\tif ( session is null )\r\n\t\t\treturn 0;\r\n\r\n\t\tvar targets = picked\r\n\t\t\t.Where( e => e.Impostor is not null )\r\n\t\t\t.SelectMany( e => e.Renderers.Select( r => (Renderer: r, e.Impostor) ) )\r\n\t\t\t.Where( x => x.Renderer.IsValid() && !x.Renderer.Components.Get<FlatpackRenderer>().IsValid() )\r\n\t\t\t.ToArray();\r\n\r\n\t\tif ( targets.Length == 0 )\r\n\t\t\treturn 0;\r\n\r\n\t\tusing var scene = session.Scene.Push();\r\n\t\tusing ( session.UndoScope( $\"Flatpack {targets.Length} Objects\" ).WithComponentCreations().Push() )\r\n\t\t{\r\n\t\t\tforeach ( var (renderer, impostor) in targets )\r\n\t\t\t{\r\n\t\t\t\tvar flatpack = renderer.GameObject.Components.Create<FlatpackRenderer>();\r\n\t\t\t\tflatpack.Source = renderer;\r\n\t\t\t\tflatpack.Impostor = impostor;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tsession.HasUnsavedChanges = true;\r\n\t\treturn targets.Length;\r\n\t}\r\n\r\n\tvoid RemoveAll()\r\n\t{\r\n\t\tvar session = SceneEditorSession.Active;\r\n\t\tif ( session is null )\r\n\t\t\treturn;\r\n\r\n\t\tvar all = session.Scene.GetAllComponents<FlatpackRenderer>().ToArray();\r\n\t\tif ( all.Length == 0 )\r\n\t\t{\r\n\t\t\t_status.Text = \"No Flatpack Renderers in this scene.\";\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tusing ( session.Scene.Push() )\r\n\t\tusing ( session.UndoScope( $\"Remove Flatpack from {all.Length} Objects\" ).WithComponentDestructions( all ).Push() )\r\n\t\t{\r\n\t\t\tforeach ( var c in all )\r\n\t\t\t\tc.Destroy();\r\n\t\t}\r\n\r\n\t\tsession.HasUnsavedChanges = true;\r\n\t\t_status.Text = $\"Removed {all.Length:n0} Flatpack Renderer(s). Ctrl+Z puts them back.\";\r\n\t\tScan();\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "anakin.flatpack",
            "Path": "FlatpackImpostor.cs",
            "FileName": "FlatpackImpostor.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 429259,
            "IsPrivate": false,
            "Code": "using System.Linq;\nusing System.Text.Json.Serialization;\nusing Sandbox;\n\nnamespace Flatpack;\n\n/// <summary>\n/// A baked impostor: the atlas material plus everything the runtime needs to place the card.\n/// Created by the Flatpack baker (Editor menu, Flatpack, Impostor Baker), one per source model.\n/// </summary>\n/// <remarks>\n/// <para>Use it with <see cref=\"FlatpackRenderer\"/>, which swaps between the real model and the card by distance.</para>\n/// <para>\n/// To use the card as the last LOD of a <c>.vmdl</c> instead, add a LOD to the model in ModelDoc whose only mesh is a\n/// single quad (any size, centred on the model) with this impostor's material, UVs (0,0) top-left to (1,1)\n/// bottom-right. The shader ignores the quad's positions and rebuilds the card from its UVs, the bake bounds and the\n/// instance transform, so the quad's size and orientation don't matter. Flatpack doesn't write <c>.vmdl</c> files for you.\n/// </para>\n/// </remarks>\n[AssetType( Name = \"Flatpack Impostor\", Extension = \"fpack\", Category = \"Rendering\" )]\npublic sealed class FlatpackImpostor : GameResource\n{\n\t/// <summary>\n\t/// The model this impostor was baked from.\n\t/// </summary>\n\t[Property] public Model SourceModel { get; set; }\n\n\t/// <summary>\n\t/// Material using <c>shaders/flatpack_impostor.shader</c> with the baked atlases.\n\t/// </summary>\n\t[Property] public Material Material { get; set; }\n\n\t/// <summary>\n\t/// How the frames are spread over view directions.\n\t/// </summary>\n\t[Property, ReadOnly] public FlatpackLayout Layout { get; set; } = FlatpackLayout.Hemisphere;\n\n\t/// <summary>\n\t/// Frames per atlas side. The atlas holds GridSize x GridSize frames.\n\t/// </summary>\n\t[Property, ReadOnly] public int GridSize { get; set; } = 8;\n\n\t/// <summary>\n\t/// Atlas width and height in pixels.\n\t/// </summary>\n\t[Property, ReadOnly] public int AtlasSize { get; set; } = 2048;\n\n\t/// <summary>\n\t/// Centre of the baked bounding sphere, in the model's object space (the pivot offset).\n\t/// </summary>\n\t[Property, ReadOnly] public Vector3 Center { get; set; }\n\n\t/// <summary>\n\t/// Radius of the baked bounding sphere, in object space units.\n\t/// </summary>\n\t[Property, ReadOnly] public float Radius { get; set; } = 1.0f;\n\n\t/// <summary>\n\t/// The source model's render bounds at bake time.\n\t/// </summary>\n\t[Property, ReadOnly] public BBox Bounds { get; set; }\n\n\t/// <summary>\n\t/// How much alpha the baker had to add back at mip level 2 to keep thin parts covered (1 = none). High values mean\n\t/// twigs, spindles or wire mesh that the card keeps only from further away; the scene optimiser warns about them.\n\t/// </summary>\n\t[Property, ReadOnly] public float ThinDetail { get; set; } = 1.0f;\n\n\t/// <summary>\n\t/// Quality level the bake was made at (Low, Medium, High, Ultra or Custom).\n\t/// </summary>\n\t[Property, ReadOnly] public string QualityLevel { get; set; }\n\n\t/// <summary>\n\t/// Nearest distance, in bounding radii, from which the baker measured the card matching its model: at most 15% of\n\t/// pixels clearly different from there out to 16 radii. 0 when not measured, -1 when it never got there.\n\t/// <see cref=\"FlatpackRenderer\"/>'s automatic swap distance never goes closer than this.\n\t/// </summary>\n\t[Property, ReadOnly] public float HoldsUpFrom { get; set; }\n\n\t/// <summary>\n\t/// Share of the covered texels that are only partly covered (0 to 1). Above <c>0.4</c> the bake is treated as\n\t/// see-through detail (wire mesh, twigs, nets) and drawn with a lowered alpha cut the baker fits to the model.\n\t/// </summary>\n\t[Property, ReadOnly] public float SeeThrough { get; set; }\n\n\tModel _cardModel;\n\tMaterial _cardMaterial;\n\n\t/// <summary>\n\t/// The shared card model for this impostor: one quad with the impostor material. Built on first use and reused\n\t/// by every <see cref=\"FlatpackRenderer\"/> that points here, so identical cards can be instanced by the engine.\n\t/// </summary>\n\t[JsonIgnore, Hide]\n\tpublic Model CardModel\n\t{\n\t\tget\n\t\t{\n\t\t\tif ( _cardModel is not null && _cardMaterial == Material )\n\t\t\t\treturn _cardModel;\n\n\t\t\t_cardMaterial = Material;\n\t\t\t_cardModel = FlatpackCard.Build( this );\n\t\t\treturn _cardModel;\n\t\t}\n\t}\n\n\tTexture[] _atlases;\n\tMaterial _atlasMaterial;\n\n\t/// <summary>\n\t/// Tell texture streaming this impostor's atlases are on screen and needed at full size.\n\t/// </summary>\n\t/// <remarks>\n\t/// The streamer can't judge the card's real texel density: the card shows one frame (1/GridSize of the atlas)\n\t/// across the whole quad, and the shader picks UVs itself. Left alone it kept a 2048 atlas at 512 or 256 and the\n\t/// impostor looked blurred. <see cref=\"FlatpackSystem\"/> calls this every frame for impostors with a visible card.\n\t/// </remarks>\n\tpublic void MarkAtlasesUsed()\n\t{\n\t\tif ( Material is null )\n\t\t\treturn;\n\n\t\tif ( _atlases is null || _atlasMaterial != Material )\n\t\t{\n\t\t\t_atlasMaterial = Material;\n\t\t\t// Bakes from before iteration 8 use the combined normal and depth atlas; later ones the separate pair.\n\t\t\t_atlases = new[] { \"g_tFlatpackAlbedo\", \"g_tFlatpackNormalDepth\", \"g_tFlatpackNormal\", \"g_tFlatpackDepth\", \"g_tFlatpackDepth8\", \"g_tFlatpackDepthMin\", \"g_tFlatpackFrames\", \"g_tFlatpackRoughnessAo\" }\n\t\t\t\t.Select( Material.GetTexture )\n\t\t\t\t.Where( t => t is not null )\n\t\t\t\t.ToArray();\n\t\t}\n\n\t\tforeach ( var texture in _atlases )\n\t\t\ttexture.MarkUsed( AtlasSize );\n\t}\n\n\tprotected override void PostReload()\n\t{\n\t\tbase.PostReload();\n\n\t\t// Rebuild the card next time it's asked for, in case a re-bake changed the bounds.\n\t\t_cardModel = null;\n\t\t_atlases = null;\n\t}\n}\n"
        }
    ]
}