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