Editor/Prism/Preview/PreviewViewport.cs

An editor viewport widget for Prism preview. It hosts a PreviewScene and PostProcessPreview, implements orbit/dolly/pan/zoom camera controls, time ticking for previews, channel and stage selection for debug views, model/mesh swapping, and exposes camera state and events.

File AccessNative Interop
using Editor.Prism.Core;
using Editor.Prism.Model;
using EngineModel = Sandbox.Model;

namespace Editor.Prism.Preview;

/// <summary>
/// The hardware-rendered 3D viewport: a camera rig, orbit/dolly/pan/zoom input, frame-selection, and
/// the per-frame pushes the preview shader depends on.
/// <para>
/// <see cref="SceneRenderingWidget"/> is the only supported way to get a real viewport into a Qt
/// widget, and it comes with three rules this class exists to obey. It never destroys the scene it is
/// given, so <see cref="OnDestroyed"/> does — otherwise every opened window leaks a whole
/// <c>SceneWorld</c>. Its <c>PreFrame</c> already runs inside <c>Scene.Push()</c> and
/// <c>GizmoInstance.Push()</c>, so nothing here re-pushes them. And it never ticks the scene, so
/// <see cref="PreviewScene.Tick"/> must be called here or nothing in the scene ever updates.
/// </para>
/// <para>
/// The widget is a native window with painting suppressed, so no overlay can be drawn onto it with
/// <c>Paint</c>. Everything the user sees on top of the render — the toolbar, the channel strip, the
/// status line — is a sibling widget owned by <see cref="PreviewPanel"/>.
/// </para>
/// </summary>
public sealed class PreviewViewport : SceneRenderingWidget
{
	/// <summary>Uniform the preview clock is pushed under, so a graph can animate on preview time.</summary>
	public const string TimeAttribute = "g_flPrismTime";

	/// <summary>Degrees of camera rotation per pixel of drag.</summary>
	public const float OrbitSensitivity = 0.25f;

	/// <summary>Fraction of the orbit distance the right-drag dolly covers per pixel.</summary>
	public const float DollyFraction = 0.01f;

	/// <summary>Fraction of the orbit distance a middle-drag pan covers per pixel.</summary>
	public const float PanFraction = 0.0025f;

	/// <summary>Distance multiplier applied per wheel notch. Below one, so scrolling up zooms in.</summary>
	public const float ZoomStep = 0.88f;

	/// <summary>Closest and furthest the camera is allowed to orbit.</summary>
	public const float MinDistance = 2f;

	/// <summary>Closest and furthest the camera is allowed to orbit.</summary>
	public const float MaxDistance = 8000f;

	PreviewScene _scene;
	PostProcessPreview _postProcess;

	float _yaw = 135f;
	float _pitch = 30f;
	float _distance = 150f;
	Vector3 _panOffset;

	Vector2 _lastCursor;
	bool _orbiting;
	bool _dollying;
	bool _panning;
	bool _orbitLights;
	bool _dragChanged;
	bool _faulted;

	float _time;
	float _timeScale = 1f;
	bool _paused;

	float _rotateSpeed = 24f;
	bool _autoRotate;

	PreviewChannel _channel = PreviewChannel.Final;
	bool _nodePreview;
	int _stageId;

	/// <summary>Build the viewport and its scene.</summary>
	public PreviewViewport( Widget parent = null ) : base( parent )
	{
		MouseTracking = true;
		FocusMode = FocusMode.Click;
		MinimumSize = new Vector2( 96, 96 );

		_scene = new PreviewScene();
		_postProcess = new PostProcessPreview();

		Scene = _scene.Scene;
		Camera = _scene.Camera;

		Attributes.Target = _scene.Subject;
		_scene.SubjectChanged += OnSubjectChanged;

		_postProcess.Attach( _scene.Camera );
		_postProcess.Attributes = Attributes.Attributes;

		ApplyChannel();
		PlaceCamera();
	}

	/// <summary>The scene rig: lights, environment, ground and the subject the material is applied to.</summary>
	public PreviewScene PreviewScene => _scene;

	/// <summary>The live-uniform channel. Values survive a mesh swap.</summary>
	public PreviewAttributeBus Attributes { get; } = new();

	/// <summary>The post-process blit path, used when the graph's domain is post-process.</summary>
	public PostProcessPreview PostProcess => _postProcess;

	/// <summary>Raised once at the end of a camera drag, so the document records one undo entry.</summary>
	public event Action CameraMoved;

	/// <summary>Raised every rendered frame, after the scene has been ticked. Runs on the main thread.</summary>
	public event Action Ticked;

	// ---- camera ------------------------------------------------------------

	/// <summary>Orbit yaw, in degrees.</summary>
	public float Yaw
	{
		get => _yaw;
		set
		{
			_yaw = value.NormalizeDegrees();
			PlaceCamera();
		}
	}

	/// <summary>Orbit pitch, in degrees, clamped to what a look-at camera can express.</summary>
	public float Pitch
	{
		get => _pitch;
		set
		{
			_pitch = Math.Clamp( value, -89f, 89f );
			PlaceCamera();
		}
	}

	/// <summary>Orbit distance, in world units.</summary>
	public float Distance
	{
		get => _distance;
		set
		{
			_distance = Math.Clamp( value, MinDistance, MaxDistance );
			PlaceCamera();
		}
	}

	/// <summary>Pan offset from the subject's centre, in subject space.</summary>
	public Vector3 PanOffset
	{
		get => _panOffset;
		set
		{
			_panOffset = value;
			PlaceCamera();
		}
	}

	/// <summary>Vertical field of view, in degrees.</summary>
	public float FieldOfView
	{
		get => Camera.IsValid() ? Camera.FieldOfView : 45f;
		set
		{
			if ( Camera.IsValid() ) Camera.FieldOfView = Math.Clamp( value, 5f, 140f );
		}
	}

	// ---- presentation ------------------------------------------------------

	/// <summary>The debug channel shown instead of the shaded result.</summary>
	public PreviewChannel Channel
	{
		get => _channel;
		set
		{
			if ( _channel == value ) return;

			_channel = value;

			ApplyChannel();
		}
	}

	/// <summary>
	/// Whether the focused node's value replaces the shader's own output. Costs one uniform and zero
	/// compiles; the preview build of the shader implements the switch.
	/// </summary>
	public bool NodePreviewEnabled
	{
		get => _nodePreview;
		set
		{
			if ( _nodePreview == value ) return;

			_nodePreview = value;

			ApplyStage();
		}
	}

	/// <summary>Which node's value the shader outputs when <see cref="NodePreviewEnabled"/> is on.</summary>
	public int StageId
	{
		get => _stageId;
		set
		{
			if ( _stageId == value ) return;

			_stageId = value;

			ApplyStage();
		}
	}

	/// <summary>Whether the preview clock advances.</summary>
	public bool Paused
	{
		get => _paused;
		set => _paused = value;
	}

	/// <summary>Rate the preview clock runs at. Zero freezes it without resetting it.</summary>
	public float TimeScale
	{
		get => _timeScale;
		set => _timeScale = Math.Clamp( value, 0f, 16f );
	}

	/// <summary>The preview clock, in seconds. Pushed as <see cref="TimeAttribute"/>.</summary>
	public float Time => _time;

	/// <summary>Whether the subject spins on its own, which is the fastest way to read a reflection.</summary>
	public bool AutoRotate
	{
		get => _autoRotate;
		set => _autoRotate = value;
	}

	/// <summary>Auto-rotation rate, in degrees per second.</summary>
	public float RotateSpeed
	{
		get => _rotateSpeed;
		set => _rotateSpeed = value;
	}

	// ---- operations --------------------------------------------------------

	/// <summary>Restart the preview clock.</summary>
	public void ResetTime() => _time = 0f;

	/// <summary>Frame the subject: dolly out until its bounding sphere fits, and clear any pan.</summary>
	public void FrameSubject()
	{
		if ( _scene is null || !Camera.IsValid() ) return;

		PrismLog.Guard( "Framing the preview subject", () =>
		{
			var bounds = _scene.SubjectBounds;
			var radius = MathF.Max( bounds.Size.Length * 0.5f, 1f );
			var distance = MathX.SphereCameraDistance( radius, Camera.FieldOfView );
			var aspect = Size.y > 0.001f ? Size.x / Size.y : 1f;

			if ( aspect > 1f ) distance *= aspect;

			_panOffset = Vector3.Zero;
			_distance = Math.Clamp( distance * 1.05f, MinDistance, MaxDistance );
		} );

		PlaceCamera();
		CameraMoved?.Invoke();
	}

	/// <summary>Put the camera back on the default three-quarter view and frame the subject.</summary>
	public void ResetCamera()
	{
		_yaw = 135f;
		_pitch = 30f;
		_panOffset = Vector3.Zero;

		FrameSubject();
	}

	/// <summary>Load the serialized orbit state without raising <see cref="CameraMoved"/>.</summary>
	public void ApplyCameraState( PreviewCamera camera )
	{
		if ( camera is null ) return;

		_yaw = camera.Yaw;
		_pitch = Math.Clamp( camera.Pitch, -89f, 89f );
		_distance = Math.Clamp( camera.SafeDistance, MinDistance, MaxDistance );

		PlaceCamera();
	}

	/// <summary>Copy the live orbit state back into a serializable one.</summary>
	public void ReadCameraState( PreviewCamera camera )
	{
		if ( camera is null ) return;

		camera.Yaw = _yaw;
		camera.Pitch = _pitch;
		camera.Distance = _distance;
	}

	/// <summary>Swap the previewed mesh. Attribute state is preserved across the swap.</summary>
	public void SetModel( EngineModel model )
	{
		if ( _scene is null ) return;

		_scene.Model = model;
	}

	/// <summary>Swap the previewed mesh by built-in name, or by asset path when one is given.</summary>
	public void SetMesh( string mesh, string modelPath = null )
	{
		if ( _scene is null ) return;

		if ( !string.IsNullOrWhiteSpace( modelPath ) )
		{
			var loaded = PrismLog.Guard( $"Loading preview model '{modelPath}'",
				() => EngineModel.Load( modelPath ), null );

			if ( loaded is not null )
			{
				_scene.Model = loaded;
				return;
			}
		}

		_scene.Model = PreviewMeshes.Resolve( mesh );
	}

	// ---- frame -------------------------------------------------------------

	/// <inheritdoc/>
	protected override void PreFrame()
	{
		base.PreFrame();

		if ( _scene is null || !_scene.IsValid ) return;

		// Written as a plain try rather than PrismLog.Guard because this is the per-frame path and the
		// guard's closure would allocate on every single frame. A fault is reported once, then the
		// viewport keeps drawing rather than filling the console at frame rate.
		try
		{
			if ( !_paused ) _time += RealTime.Delta * _timeScale;

			if ( _autoRotate && !_orbitLights )
			{
				_scene.SubjectYaw = ( _scene.SubjectYaw + RealTime.Delta * _rotateSpeed ).NormalizeDegrees();
			}

			UpdateDrag();

			_scene.Tick( RealTime.Delta );

			if ( Camera.IsValid() ) Camera.CustomSize = Size * DpiScale;

			Attributes.Set( TimeAttribute, _time );

			PlaceCamera();
		}
		catch ( Exception e )
		{
			if ( !_faulted )
			{
				_faulted = true;
				PrismLog.Error( e, "The Prism preview frame failed" );
			}
		}

		Ticked?.Invoke();
	}

	/// <inheritdoc/>
	public override void OnDestroyed()
	{
		base.OnDestroyed();

		if ( _scene is not null ) _scene.SubjectChanged -= OnSubjectChanged;

		// The command list has to come off the camera before the scene that owns it is destroyed.
		_postProcess?.Dispose();
		_postProcess = null;

		Attributes.Target = null;

		Camera = null;
		Scene = null;

		_scene?.Dispose();
		_scene = null;
	}

	// ---- input -------------------------------------------------------------

	/// <inheritdoc/>
	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		_orbitLights = e.HasCtrl;
		_lastCursor = CursorPosition;
		_dragChanged = false;

		if ( e.LeftMouseButton ) _orbiting = true;
		else if ( e.RightMouseButton ) _dollying = true;
		else if ( e.MiddleMouseButton ) _panning = true;
	}

	/// <inheritdoc/>
	protected override void OnMouseReleased( MouseEvent e )
	{
		base.OnMouseReleased( e );

		if ( e.LeftMouseButton )
		{
			_orbiting = false;
			_orbitLights = false;
		}
		else if ( e.RightMouseButton )
		{
			_dollying = false;
		}
		else if ( e.MiddleMouseButton )
		{
			_panning = false;
		}

		if ( _orbiting || _dollying || _panning ) return;

		Cursor = CursorShape.None;

		if ( !_dragChanged ) return;

		_dragChanged = false;
		CameraMoved?.Invoke();
	}

	/// <inheritdoc/>
	protected override void OnMouseWheel( WheelEvent e )
	{
		base.OnMouseWheel( e );

		// Qt reports 120 units per detent. Zooming multiplicatively keeps the step feeling identical
		// whether the camera is inside the mesh or across the room.
		var notches = e.Delta / 120f;

		_distance = Math.Clamp( _distance * MathF.Pow( ZoomStep, notches ), MinDistance, MaxDistance );

		PlaceCamera();
		CameraMoved?.Invoke();
	}

	/// <inheritdoc/>
	protected override void OnKeyPress( KeyEvent e )
	{
		base.OnKeyPress( e );

		if ( e.Key == KeyCode.Control )
		{
			_orbitLights = true;
			return;
		}

		if ( e.Key == KeyCode.F )
		{
			FrameSubject();
			return;
		}

		if ( e.Key == KeyCode.Home )
		{
			ResetCamera();
		}
	}

	/// <inheritdoc/>
	protected override void OnKeyRelease( KeyEvent e )
	{
		base.OnKeyRelease( e );

		if ( e.Key == KeyCode.Control ) _orbitLights = false;
	}

	/// <summary>
	/// <c>Application.CursorPosition</c> is wrong on any display that is not at 100% scaling, which is
	/// why every drag delta in the editor is measured in unscaled coordinates.
	/// </summary>
	static Vector2 CursorPosition => Application.UnscaledCursorPosition;

	void UpdateDrag()
	{
		if ( !_orbiting && !_dollying && !_panning )
		{
			_lastCursor = CursorPosition;
			return;
		}

		var cursor = CursorPosition;
		var delta = cursor - _lastCursor;

		if ( delta.Length > 0.0001f )
		{
			_dragChanged = true;

			if ( _orbiting )
			{
				if ( _orbitLights )
				{
					// Rotating the subject instead of the camera keeps the lighting rig still, which is
					// the only way to judge a reflection or an anisotropic highlight.
					_scene.SubjectYaw = ( _scene.SubjectYaw - delta.x * OrbitSensitivity ).NormalizeDegrees();
				}
				else
				{
					_yaw = ( _yaw + delta.x * OrbitSensitivity ).NormalizeDegrees();
					_pitch = Math.Clamp( _pitch + delta.y * OrbitSensitivity, -89f, 89f );
				}
			}
			else if ( _dollying )
			{
				var step = MathF.Max( _distance, MinDistance ) * DollyFraction;

				_distance = Math.Clamp( _distance + delta.y * step, MinDistance, MaxDistance );
			}
			else if ( _panning && Camera.IsValid() )
			{
				var rotation = Camera.WorldRotation;
				var scale = MathF.Max( _distance, MinDistance ) * PanFraction;
				var right = rotation.Right * ( delta.x * scale );
				var down = rotation.Down * ( delta.y * scale );
				var inverse = Rotation.FromYaw( _scene.SubjectYaw ).Inverse;

				_panOffset += right * inverse;
				_panOffset += down * inverse;
			}
		}

		// Warping the cursor back gives an infinite drag surface, which is what makes orbiting feel right.
		Application.UnscaledCursorPosition = _lastCursor;
		Cursor = CursorShape.Blank;
	}

	void PlaceCamera()
	{
		if ( _scene is null || !Camera.IsValid() ) return;

		try
		{
			var rotation = new Angles( _pitch, -_yaw, 0f ).ToRotation();
			var subject = Rotation.FromYaw( _scene.SubjectYaw );
			var pivot = ( _panOffset + _scene.SubjectBounds.Center ) * subject;

			Camera.WorldRotation = rotation;
			Camera.WorldPosition = pivot + rotation.Backward * _distance;
		}
		catch ( Exception e )
		{
			if ( _faulted ) return;

			_faulted = true;
			PrismLog.Error( e, "Placing the preview camera failed" );
		}
	}

	void ApplyChannel()
	{
		PreviewChannels.Apply( _channel, Camera );
		PreviewChannels.Apply( _channel, Attributes.Attributes );

		// Cached too, so a mesh swap does not silently drop back to the shaded view.
		Attributes.Set( PreviewChannels.AttributeName, (int)_channel );
	}

	void ApplyStage() => Attributes.Set( PrismConstants.StageIdAttribute, _nodePreview ? _stageId : 0 );

	void OnSubjectChanged( SceneModel subject )
	{
		Attributes.Target = subject;

		if ( _postProcess is not null ) _postProcess.Attributes = Attributes.Attributes;
	}
}