FlatpackRenderer.cs

The FlatpackRenderer component manages Level of Detail transitions between a source model and a baked impostor. It controls switching behavior, model LOD progression, and the visual crossfade effect.

Native Interop
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;
	}
}