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.
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;
}
}