PreviewSettings is an editor-side settings wrapper for the shader preview system. It exposes properties that either read/write the document-backed PreviewState (undoable, saved in the graph) or editor-wide preferences stored in cookies, provides Load/Save for preferences, ApplyTo to push settings into a PreviewViewport, and binding to a PrismGraph/GraphMutations for undoable edits.
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Undo;
namespace Editor.Prism.Preview;
/// <summary>
/// The preview's settings surface: one bindable object the settings popovers build a
/// <c>ControlSheet</c> over, backed by two very different stores.
/// <para>
/// Anything that describes <em>this shader</em> — the mesh, the environment, the tint, the background,
/// the debug channel, the camera — lives in the document's <see cref="PreviewState"/> and is written
/// through <see cref="GraphMutations.UpdatePreview"/>, so it is undoable and it is saved with the
/// graph. Reopening a document restores exactly the framing its author left it in.
/// </para>
/// <para>
/// Anything that describes <em>how this user likes to look at shaders</em> — shadows, fill lights,
/// tonemapping, auto-rotation, thumbnail rendering — is an editor preference stored in cookies and
/// shared by every document, because nobody wants their lighting rig to change when they open someone
/// else's graph.
/// </para>
/// <para>
/// Getters read straight through to the live state, so nothing can drift out of sync, and setters are
/// inert while <see cref="Load"/> is applying, so restoring a document never writes an undo entry.
/// </para>
/// </summary>
public sealed class PreviewSettings
{
/// <summary>Cookie prefix for the editor-wide half of these settings.</summary>
public const string CookiePrefix = "prism.preview.";
readonly PreviewState _detached = new();
bool _suspend;
bool _enableShadows = true;
bool _enableFillLights = true;
bool _enableTonemapping = true;
bool _renderBackfaces;
bool _autoRotate;
bool _nodeThumbnails;
bool _nodePreview;
Angles _sunAngle = new( 48f, 40f, 0f );
Color _sunColor = new( 0.98f, 0.97f, 0.94f );
float _sunBrightness = 2.2f;
float _ambientStrength = 0.08f;
float _rotateSpeed = 24f;
float _fieldOfView = 45f;
/// <summary>Create an unbound settings object backed by a detached preview state.</summary>
public PreviewSettings()
{
Load();
}
/// <summary>The document these settings edit, or null when unbound.</summary>
public PrismGraph Graph { get; private set; }
/// <summary>The mutation API document edits go through. Null means edits are not undoable.</summary>
public GraphMutations Mutations { get; private set; }
/// <summary>The state being read and written. Falls back to a detached instance when unbound.</summary>
public PreviewState State => Graph?.Preview ?? _detached;
/// <summary>Raised after any setting changes, from either store.</summary>
public event Action Changed;
/// <summary>Bind to a document. Pass nulls to unbind.</summary>
public void Bind( PrismGraph graph, GraphMutations mutations )
{
Graph = graph;
Mutations = mutations;
Changed?.Invoke();
}
/// <summary>Run an action without any of its property writes reaching the document or the cookies.</summary>
public IDisposable Suspend() => new SuspendScope( this );
// ---- document-backed ---------------------------------------------------
/// <summary>Built-in mesh name. Ignored while <see cref="ModelPath"/> is set.</summary>
public string Mesh
{
get => string.IsNullOrWhiteSpace( State.Mesh ) ? "Sphere" : State.Mesh;
set => Edit( x => x.Mesh = value, "Change Preview Mesh" );
}
/// <summary>Custom model asset path. Takes precedence over <see cref="Mesh"/>.</summary>
public string ModelPath
{
get => State.Model;
set => Edit( x => x.Model = value, "Change Preview Model" );
}
/// <summary>Environment asset: a skybox <c>.vmat</c> or a cubemap texture.</summary>
public string Envmap
{
get => State.Envmap;
set => Edit( x => x.Envmap = value, "Change Preview Environment" );
}
/// <summary>Whether the ground plane is drawn.</summary>
public bool ShowGround
{
get => State.ShowGround;
set => Edit( x => x.ShowGround = value, "Toggle Preview Ground" );
}
/// <summary>Whether the skybox is drawn.</summary>
public bool ShowSkybox
{
get => State.ShowSkybox;
set => Edit( x => x.ShowSkybox = value, "Toggle Preview Skybox" );
}
/// <summary>Per-instance tint multiplied into the subject.</summary>
public Color Tint
{
get => State.Tint;
set => Edit( x => x.Tint = value, "Change Preview Tint" );
}
/// <summary>Viewport clear colour.</summary>
public Color Background
{
get => State.Background;
set => Edit( x => x.Background = value, "Change Preview Background" );
}
/// <summary>The debug channel currently shown.</summary>
public PreviewChannel Channel
{
get => PreviewChannels.Parse( State.Channel );
set => Edit( x => x.Channel = PreviewChannels.Id( value ), "Change Preview Channel" );
}
/// <summary>The saved orbit state.</summary>
public PreviewCamera Camera => State.Camera ??= new PreviewCamera();
/// <summary>
/// Record the camera the user just moved to, as a single undo entry. Called once when a drag ends,
/// never per frame — an orbit that produced two hundred history entries would be unusable.
/// </summary>
public void CommitCamera( float yaw, float pitch, float distance )
{
Edit( x =>
{
x.Camera ??= new PreviewCamera();
x.Camera.Yaw = yaw;
x.Camera.Pitch = pitch;
x.Camera.Distance = distance;
}, "Move Preview Camera" );
}
// ---- editor preferences ------------------------------------------------
/// <summary>Whether the key light casts shadows.</summary>
public bool EnableShadows
{
get => _enableShadows;
set => Prefer( ref _enableShadows, value, "shadows" );
}
/// <summary>Whether the warm and cool rim lights are on.</summary>
public bool EnableFillLights
{
get => _enableFillLights;
set => Prefer( ref _enableFillLights, value, "fillLights" );
}
/// <summary>Whether the tonemapping post-process runs.</summary>
public bool EnableTonemapping
{
get => _enableTonemapping;
set => Prefer( ref _enableTonemapping, value, "tonemapping" );
}
/// <summary>Whether back faces are drawn.</summary>
public bool RenderBackfaces
{
get => _renderBackfaces;
set => Prefer( ref _renderBackfaces, value, "backfaces" );
}
/// <summary>Whether the subject spins on its own.</summary>
public bool AutoRotate
{
get => _autoRotate;
set => Prefer( ref _autoRotate, value, "autoRotate" );
}
/// <summary>Auto-rotation rate in degrees per second.</summary>
public float RotateSpeed
{
get => _rotateSpeed;
set => Prefer( ref _rotateSpeed, Math.Clamp( value, -360f, 360f ), "rotateSpeed" );
}
/// <summary>Whether per-node thumbnails are rendered into the graph view.</summary>
public bool NodeThumbnails
{
get => _nodeThumbnails;
set => Prefer( ref _nodeThumbnails, value, PrismConstants.CookieNodePreviews, absolute: true );
}
/// <summary>Whether the selected node's value replaces the shader output in the main viewport.</summary>
public bool NodePreview
{
get => _nodePreview;
set => Prefer( ref _nodePreview, value, "nodePreview" );
}
/// <summary>Key-light direction.</summary>
public Angles SunAngle
{
get => _sunAngle;
set => Prefer( ref _sunAngle, value, "sunAngle" );
}
/// <summary>Key-light colour.</summary>
public Color SunColor
{
get => _sunColor;
set => Prefer( ref _sunColor, value, "sunColor" );
}
/// <summary>Key-light intensity.</summary>
public float SunBrightness
{
get => _sunBrightness;
set => Prefer( ref _sunBrightness, Math.Clamp( value, 0f, 32f ), "sunBrightness" );
}
/// <summary>Flat ambient term on top of the environment probe.</summary>
public float AmbientStrength
{
get => _ambientStrength;
set => Prefer( ref _ambientStrength, Math.Clamp( value, 0f, 4f ), "ambient" );
}
/// <summary>Camera vertical field of view, in degrees.</summary>
public float FieldOfView
{
get => _fieldOfView;
set => Prefer( ref _fieldOfView, Math.Clamp( value, 5f, 140f ), "fov" );
}
// ---- persistence -------------------------------------------------------
/// <summary>Read the editor-wide half back out of the cookie store.</summary>
public void Load()
{
using ( Suspend() )
{
_enableShadows = Cookie( "shadows", _enableShadows );
_enableFillLights = Cookie( "fillLights", _enableFillLights );
_enableTonemapping = Cookie( "tonemapping", _enableTonemapping );
_renderBackfaces = Cookie( "backfaces", _renderBackfaces );
_autoRotate = Cookie( "autoRotate", _autoRotate );
_rotateSpeed = Cookie( "rotateSpeed", _rotateSpeed );
_nodePreview = Cookie( "nodePreview", _nodePreview );
_sunAngle = Cookie( "sunAngle", _sunAngle );
_sunColor = Cookie( "sunColor", _sunColor );
_sunBrightness = Cookie( "sunBrightness", _sunBrightness );
_ambientStrength = Cookie( "ambient", _ambientStrength );
_fieldOfView = Cookie( "fov", _fieldOfView );
_nodeThumbnails = PrismLog.Guard( "Reading a preview preference",
() => EditorCookie.Get( PrismConstants.CookieNodePreviews, _nodeThumbnails ), _nodeThumbnails );
}
}
/// <summary>Write the editor-wide half back to the cookie store.</summary>
public void Save()
{
Store( "shadows", _enableShadows );
Store( "fillLights", _enableFillLights );
Store( "tonemapping", _enableTonemapping );
Store( "backfaces", _renderBackfaces );
Store( "autoRotate", _autoRotate );
Store( "rotateSpeed", _rotateSpeed );
Store( "nodePreview", _nodePreview );
Store( "sunAngle", _sunAngle );
Store( "sunColor", _sunColor );
Store( "sunBrightness", _sunBrightness );
Store( "ambient", _ambientStrength );
Store( "fov", _fieldOfView );
// Shared with the View menu and the preferences page, which read it through PrismCookies. That
// class memoises, so it drops its cache when the preferences page opens rather than this layer
// reaching upwards into Integration to tell it to.
PrismLog.Guard( "Writing a preview preference",
() => EditorCookie.Set( PrismConstants.CookieNodePreviews, _nodeThumbnails ) );
}
/// <summary>Push every setting onto a live viewport in one pass.</summary>
public void ApplyTo( PreviewViewport viewport )
{
if ( viewport is null ) return;
var scene = viewport.PreviewScene;
if ( scene is not null )
{
scene.ShowGround = ShowGround;
scene.ShowSkybox = ShowSkybox;
scene.EnableShadows = EnableShadows;
scene.EnableFillLights = EnableFillLights;
scene.EnableTonemapping = EnableTonemapping;
scene.RenderBackfaces = RenderBackfaces;
scene.BackgroundColor = Background;
scene.Tint = Tint;
scene.SunAngles = SunAngle;
scene.SunColor = SunColor;
scene.SunBrightness = SunBrightness;
scene.AmbientStrength = AmbientStrength;
scene.EnvmapPath = Envmap;
}
viewport.SetMesh( Mesh, ModelPath );
viewport.Channel = Channel;
viewport.AutoRotate = AutoRotate;
viewport.RotateSpeed = RotateSpeed;
viewport.NodePreviewEnabled = NodePreview;
viewport.FieldOfView = FieldOfView;
viewport.ApplyCameraState( Camera );
}
// ---- plumbing ----------------------------------------------------------
void Edit( Action<PreviewState> edit, string label )
{
if ( _suspend || edit is null ) return;
if ( Mutations is not null && Graph?.Preview is not null )
{
Mutations.UpdatePreview( edit, label );
}
else
{
PrismLog.Guard( label, () => edit( State ) );
}
Changed?.Invoke();
}
void Prefer<T>( ref T field, T value, string key, bool absolute = false )
{
if ( EqualityComparer<T>.Default.Equals( field, value ) ) return;
field = value;
if ( _suspend ) return;
var cookie = absolute ? key : CookiePrefix + key;
var stored = value;
PrismLog.Guard( $"Writing preview preference '{cookie}'", () => EditorCookie.Set( cookie, stored ) );
Changed?.Invoke();
}
static T Cookie<T>( string key, T fallback ) => PrismLog.Guard( $"Reading preview preference '{key}'",
() => EditorCookie.Get( CookiePrefix + key, fallback ), fallback );
static void Store<T>( string key, T value ) => PrismLog.Guard( $"Writing preview preference '{key}'",
() => EditorCookie.Set( CookiePrefix + key, value ) );
sealed class SuspendScope : IDisposable
{
readonly PreviewSettings _owner;
readonly bool _previous;
public SuspendScope( PreviewSettings owner )
{
_owner = owner;
_previous = owner._suspend;
owner._suspend = true;
}
public void Dispose() => _owner._suspend = _previous;
}
}