Editor/Prism/Preview/PreviewScene.cs

Editor preview scene for Prism. It creates an editor Scene with camera, lights, skybox, envmap probe, tonemapping, a ground plane and a subject model, and exposes properties to control appearance and lifecycle (Tick, Dispose). It loads materials/textures by asset path and rebuilds the subject when model or material changes.

File AccessNetworking
using Editor.Prism.Core;
using EngineMaterial = Sandbox.Material;
using EngineModel = Sandbox.Model;
using EngineScene = Sandbox.Scene;

namespace Editor.Prism.Preview;

/// <summary>
/// The editor scene a shader preview is rendered in: camera, three-point lighting, an environment
/// probe, a skybox, a ground plane, tonemapping and the subject the compiled material is applied to.
/// <para>
/// This is deliberately a plain object rather than a widget. The viewport widget owns one and drives
/// it, the thumbnail service builds its own much smaller rig, and a headless caller can construct one
/// to render a shader to a bitmap. Nothing here touches Qt.
/// </para>
/// <para>
/// Two engine traps are handled here and nowhere else. The scene comes from
/// <c>Scene.CreateEditorScene()</c> because <c>Scene.EditorTick</c> silently early-returns on a scene
/// that is not an editor scene, which would leave every component frozen. And the
/// <see cref="EnvmapProbe"/> is given enormous bounds, because a probe in custom-texture mode with
/// default bounds contributes nothing at all and does so without a warning.
/// </para>
/// </summary>
public sealed class PreviewScene : IDisposable
{
	/// <summary>Default environment cubemap. The same one every stock editor preview uses.</summary>
	public const string DefaultEnvmap = "textures/cubemaps/default2.vtex";

	/// <summary>Default skybox material.</summary>
	public const string DefaultSkybox = "materials/skybox/skybox_day_01.vmat";

	/// <summary>Half-size of the envmap probe's bounds. Small bounds make the probe do nothing at all.</summary>
	public const float EnvmapBounds = 100000f;

	DirectionalLight _sun;
	AmbientLight _ambient;
	EnvmapProbe _envmap;
	SkyBox2D _sky;
	Tonemapping _tonemapping;

	readonly List<PointLight> _fill = new();

	EngineModel _model;
	EngineMaterial _material;
	SceneModel _subject;
	SceneObject _ground;

	bool _showGround;
	bool _faulted;
	bool _enableShadows = true;
	bool _enableFillLights = true;
	bool _renderBackfaces;
	Color _tint = Color.White;
	Color _background = new( 0.05f, 0.06f, 0.07f, 1f );
	Angles _sunAngles = new( 48f, 40f, 0f );
	Color _sunColor = new( 0.98f, 0.97f, 0.94f );
	float _sunBrightness = 2.2f;
	float _ambientStrength = 0.08f;
	float _subjectYaw;
	string _envmapPath;

	/// <summary>Build the rig. Never throws; a failure leaves <see cref="IsValid"/> false.</summary>
	public PreviewScene()
	{
		PrismLog.Guard( "Building the Prism preview scene", Build );
	}

	/// <summary>The editor scene. Owned by this object and destroyed by <see cref="Dispose"/>.</summary>
	public EngineScene Scene { get; private set; }

	/// <summary>The camera the viewport renders through.</summary>
	public CameraComponent Camera { get; private set; }

	/// <summary>The raw scene world, for direct <c>SceneObject</c> work.</summary>
	public SceneWorld World => Scene?.SceneWorld;

	/// <summary>The object the compiled material is applied to.</summary>
	public SceneModel Subject => _subject;

	/// <summary>The ground plane. Always present; visibility is <see cref="ShowGround"/>.</summary>
	public SceneObject Ground => _ground;

	/// <summary>True when the scene was built and has not been disposed.</summary>
	public bool IsValid => Scene.IsValid() && Camera.IsValid();

	/// <summary>Raised whenever the subject object is replaced, so attribute state can be re-pushed.</summary>
	public event Action<SceneModel> SubjectChanged;

	// ---- subject -----------------------------------------------------------

	/// <summary>
	/// The mesh being previewed. Assigning rebuilds the subject scene object, which is why every
	/// per-object attribute has to be pushed again afterwards — hence <see cref="SubjectChanged"/>.
	/// </summary>
	public EngineModel Model
	{
		get => _model;
		set
		{
			var target = value ?? PreviewMeshes.Sphere;

			if ( _model == target && _subject.IsValid() ) return;

			_model = target;
			RebuildSubject();
		}
	}

	/// <summary>The material drawn on the subject. Null restores the model's own materials.</summary>
	public EngineMaterial Material
	{
		get => _material;
		set
		{
			_material = value;
			ApplyMaterial();
		}
	}

	/// <summary>Yaw applied to the subject, in degrees. Used by the "orbit lights" modifier.</summary>
	public float SubjectYaw
	{
		get => _subjectYaw;
		set
		{
			_subjectYaw = value;

			if ( _subject.IsValid() ) _subject.Rotation = Rotation.FromYaw( _subjectYaw );
		}
	}

	/// <summary>World bounds of the subject, or a unit box when there is nothing to measure.</summary>
	public BBox SubjectBounds
	{
		get
		{
			if ( _model is not null )
			{
				var bounds = PrismLog.Guard( "Reading preview model bounds",
					() => _model.RenderBounds, BBox.FromPositionAndSize( Vector3.Zero, PreviewMeshes.Radius * 2f ) );

				if ( bounds.Size.Length > 0.001f ) return bounds;
			}

			return BBox.FromPositionAndSize( Vector3.Zero, PreviewMeshes.Radius * 2f );
		}
	}

	// ---- environment -------------------------------------------------------

	/// <summary>Whether the ground plane is drawn.</summary>
	public bool ShowGround
	{
		get => _showGround;
		set
		{
			_showGround = value;

			if ( _ground.IsValid() ) _ground.RenderingEnabled = value;
		}
	}

	/// <summary>Whether the skybox is drawn behind the subject.</summary>
	public bool ShowSkybox
	{
		get => _sky.IsValid() && _sky.Enabled;
		set
		{
			if ( _sky.IsValid() ) _sky.Enabled = value;
		}
	}

	/// <summary>Whether the key light casts shadows and the subject receives them.</summary>
	public bool EnableShadows
	{
		get => _enableShadows;
		set
		{
			_enableShadows = value;

			if ( _sun.IsValid() ) _sun.Shadows = value;
			if ( _subject.IsValid() ) _subject.Flags.CastShadows = value;
		}
	}

	/// <summary>Whether the warm and cool rim lights are on. Off gives a clean single-light read.</summary>
	public bool EnableFillLights
	{
		get => _enableFillLights;
		set
		{
			_enableFillLights = value;

			foreach ( var light in _fill )
			{
				if ( light.IsValid() ) light.Enabled = value;
			}
		}
	}

	/// <summary>Whether the tonemapping post-process runs. Off shows the raw linear output.</summary>
	public bool EnableTonemapping
	{
		get => _tonemapping.IsValid() && _tonemapping.Enabled;
		set
		{
			if ( _tonemapping.IsValid() ) _tonemapping.Enabled = value;
		}
	}

	/// <summary>
	/// Whether back faces are drawn. Pushed as the engine's <c>D_RENDER_BACKFACES</c> combo, which the
	/// generated shader declares, so this costs no recompile when the combo was compiled.
	/// </summary>
	public bool RenderBackfaces
	{
		get => _renderBackfaces;
		set
		{
			_renderBackfaces = value;

			if ( _subject.IsValid() ) _subject.Attributes.SetCombo( "D_RENDER_BACKFACES", value );
		}
	}

	/// <summary>Viewport clear colour, visible where the skybox is not.</summary>
	public Color BackgroundColor
	{
		get => _background;
		set
		{
			_background = value;

			if ( Camera.IsValid() ) Camera.BackgroundColor = value;
		}
	}

	/// <summary>Per-instance tint multiplied into the subject.</summary>
	public Color Tint
	{
		get => _tint;
		set
		{
			_tint = value;

			if ( _subject.IsValid() ) _subject.ColorTint = value;
		}
	}

	/// <summary>Key-light direction.</summary>
	public Angles SunAngles
	{
		get => _sunAngles;
		set
		{
			_sunAngles = value;

			if ( _sun.IsValid() ) _sun.WorldRotation = value.ToRotation();
		}
	}

	/// <summary>Key-light colour, before <see cref="SunBrightness"/>.</summary>
	public Color SunColor
	{
		get => _sunColor;
		set
		{
			_sunColor = value;
			ApplySun();
		}
	}

	/// <summary>Key-light intensity multiplier.</summary>
	public float SunBrightness
	{
		get => _sunBrightness;
		set
		{
			_sunBrightness = Math.Clamp( value, 0f, 32f );
			ApplySun();
		}
	}

	/// <summary>Flat ambient term, on top of the environment probe.</summary>
	public float AmbientStrength
	{
		get => _ambientStrength;
		set
		{
			_ambientStrength = Math.Clamp( value, 0f, 4f );

			if ( _ambient.IsValid() ) _ambient.Color = Color.White * _ambientStrength;
		}
	}

	/// <summary>
	/// The environment. A <c>.vmat</c> path is treated as a skybox material and a texture path as a
	/// cubemap for the probe; empty restores the defaults. Silently ignores anything that fails to
	/// load, because a missing asset must not blank the viewport.
	/// </summary>
	public string EnvmapPath
	{
		get => _envmapPath;
		set
		{
			_envmapPath = value;
			ApplyEnvironment();
		}
	}

	// ---- lifecycle ---------------------------------------------------------

	/// <summary>
	/// Advance the scene. Must be called every frame from inside the widget's <c>PreFrame</c>, which is
	/// already inside <c>Scene.Push()</c>. Without the editor tick nothing in the scene updates at all,
	/// and without the subject update its bounds and skinning never refresh.
	/// </summary>
	public void Tick( float delta )
	{
		if ( !Scene.IsValid() ) return;

		// Plain try rather than PrismLog.Guard: this is the per-frame path, and the guard's closure
		// would allocate on every frame. A repeated fault is reported once, not sixty times a second.
		try
		{
			Scene.EditorTick( RealTime.Now, RealTime.Delta );

			if ( _subject.IsValid() ) _subject.Update( delta );

			if ( _ground.IsValid() && _showGround )
			{
				_ground.Position = Vector3.Up * ( SubjectBounds.Mins.z - 0.1f );
			}
		}
		catch ( Exception e )
		{
			if ( _faulted ) return;

			_faulted = true;
			PrismLog.Error( e, "Ticking the Prism preview scene failed" );
		}
	}

	/// <summary>Destroy the scene. Skipping this leaks a <c>SceneWorld</c> per opened window.</summary>
	public void Dispose()
	{
		var scene = Scene;

		Scene = null;
		Camera = null;
		_sun = null;
		_ambient = null;
		_envmap = null;
		_sky = null;
		_tonemapping = null;
		_fill.Clear();
		_subject = null;
		_ground = null;

		PrismLog.Guard( "Destroying the preview scene", () => scene?.Destroy() );
	}

	// ---- construction ------------------------------------------------------

	void Build()
	{
		Scene = EngineScene.CreateEditorScene();

		if ( !Scene.IsValid() ) return;

		Scene.Name = "Prism Preview";

		using ( Scene.Push() )
		{
			var cameraObject = new GameObject( true, "camera" );

			Camera = cameraObject.GetOrAddComponent<CameraComponent>();
			Camera.BackgroundColor = _background;
			Camera.FieldOfView = 45f;
			Camera.ZNear = 1f;
			Camera.ZFar = 20000f;
			Camera.IsMainCamera = false;

			_tonemapping = cameraObject.GetOrAddComponent<Tonemapping>( false );
			_tonemapping.Mode = Tonemapping.TonemappingMode.ACES;
			_tonemapping.AutoExposureEnabled = false;
			_tonemapping.Enabled = true;

			_sun = new GameObject( true, "key" ).GetOrAddComponent<DirectionalLight>();
			_sun.WorldRotation = _sunAngles.ToRotation();
			_sun.Shadows = _enableShadows;

			AddFillLight( "fill.warm", new Vector3( -90f, 120f, 70f ), new Color( 1f, 0.72f, 0.45f ), 2.2f );
			AddFillLight( "fill.cool", new Vector3( 90f, -130f, 30f ), new Color( 0.42f, 0.62f, 1f ), 1.6f );

			_ambient = new GameObject( true, "ambient" ).GetOrAddComponent<AmbientLight>();

			_envmap = new GameObject( true, "envmap" ).GetOrAddComponent<EnvmapProbe>();
			_envmap.Mode = EnvmapProbe.EnvmapProbeMode.CustomTexture;
			_envmap.Bounds = BBox.FromPositionAndSize( Vector3.Zero, EnvmapBounds );

			_sky = new GameObject( true, "sky" ).GetOrAddComponent<SkyBox2D>();
		}

		ApplySun();
		AmbientStrength = _ambientStrength;
		ApplyEnvironment();

		_ground = PrismLog.Guard( "Creating the preview ground",
			() => new SceneObject( World, PreviewMeshes.Ground ), null );

		if ( _ground.IsValid() )
		{
			_ground.RenderingEnabled = _showGround;
			_ground.Flags.CastShadows = false;
		}

		Model = PreviewMeshes.Sphere;
	}

	void AddFillLight( string name, Vector3 position, Color color, float brightness )
	{
		var light = new GameObject( true, name ).GetOrAddComponent<PointLight>( false );

		light.WorldPosition = position;
		light.Radius = 900f;
		light.LightColor = color * brightness;
		light.Shadows = false;
		light.Enabled = _enableFillLights;

		_fill.Add( light );
	}

	void ApplySun()
	{
		if ( !_sun.IsValid() ) return;

		_sun.LightColor = _sunColor * _sunBrightness;
		_sun.WorldRotation = _sunAngles.ToRotation();
		_sun.Shadows = _enableShadows;
	}

	void ApplyEnvironment()
	{
		var path = _envmapPath;

		if ( _sky.IsValid() )
		{
			var materialPath = !string.IsNullOrWhiteSpace( path ) &&
				path.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase ) ? path : DefaultSkybox;

			var material = PrismLog.Guard( $"Loading skybox '{materialPath}'",
				() => EngineMaterial.Load( materialPath ), null );

			// SkyBox2D silently rejects a material whose shader name does not contain "sky", so a wrong
			// asset here simply leaves the previous sky in place rather than producing a black frame.
			if ( material is not null ) _sky.SkyMaterial = material;
		}

		if ( !_envmap.IsValid() ) return;

		var texturePath = !string.IsNullOrWhiteSpace( path ) &&
			!path.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase ) ? path : DefaultEnvmap;

		var texture = PrismLog.Guard( $"Loading envmap '{texturePath}'", () => Texture.Load( texturePath ), null )
			?? PrismLog.Guard( "Loading the default envmap", () => Texture.Load( DefaultEnvmap ), null );

		if ( texture is not null ) _envmap.Texture = texture;
	}

	void RebuildSubject()
	{
		PrismLog.Guard( "Rebuilding the preview subject", () =>
		{
			if ( _subject.IsValid() )
			{
				_subject.RenderingEnabled = false;
				_subject.Delete();
			}

			_subject = null;

			if ( World is null || _model is null ) return;

			_subject = new SceneModel( World, _model, Transform.Zero )
			{
				ColorTint = _tint,

				// Batching drops per-object render attributes, which is the entire mechanism behind live
				// parameter editing and the node-preview stage switch.
				Batchable = false
			};

			_subject.Rotation = Rotation.FromYaw( _subjectYaw );
			_subject.Flags.CastShadows = _enableShadows;
			_subject.Update( 1f );
			_subject.Attributes.SetCombo( "D_RENDER_BACKFACES", _renderBackfaces );

			ApplyMaterial();
		} );

		SubjectChanged?.Invoke( _subject );
	}

	void ApplyMaterial()
	{
		if ( !_subject.IsValid() ) return;

		PrismLog.Guard( "Applying the preview material", () =>
		{
			if ( _material is null )
			{
				_subject.ClearMaterialOverride();
				return;
			}

			_subject.SetMaterialOverride( _material );
		} );
	}
}