Editor/RigControlEditor/RigLightsPanel.cs
using Editor;
using Marionette;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Marionette.Tools;
/// <summary>
/// The Lights tab - the lighting the viewport renders this clip under, and the one place it is
/// edited.
///
/// WHY LIGHTING IS A FEATURE OF AN ANIMATION TOOL AT ALL. A pose is read off its silhouette and
/// its shadows. Under one fixed sun over the shoulder - which is what this viewport had - an arm
/// in front of a chest is a flat shape, a hand turned palm-up looks the same as palm-down, and
/// the face of anything looking away from that one direction is a grey wall. You cannot judge a
/// pose you cannot see, so lighting is not decoration here, it is the instrument.
///
/// A LIGHT LIVES ON THE CLIP, NOT IN A PREFERENCE. The useful lighting is per-shot: him at the
/// desk wants the desk's key light and the next clip wants something else. Kept as an editor
/// setting it would follow you between clips and be wrong for most of them.
///
/// AND IT IS WORKSPACE UNTIL TICKED OTHERWISE. Export With Clip is off by default, so a light
/// added to see an elbow by stays in this window; ticked, RigAnimPlayerComponent spawns it
/// beside the model when the clip plays. Neither ever reaches an exported .vmdl - that format
/// is bone channels and has nowhere to put a light - and the export dialog says so rather than
/// dropping them quietly.
///
/// Modelled on RigConstraintsPanel: a list of small objects, each a property sheet with a row of
/// buttons under it. Same shape because it is the same job, and a second layout for it would be
/// a second thing to keep in step.
/// </summary>
internal sealed class RigLightsPanel : Widget
{
private readonly RigViewport _viewport;
private readonly Widget _list;
private RigAnimDocument _anim;
/// <summary>Raised after this panel has changed the document, with the undo label. Same
/// contract as every other panel in this window: the panel edits the list, the window
/// rebuilds, marks dirty and records the undo step.</summary>
public Action<string> Changed { get; set; }
public RigLightsPanel( Widget parent, RigViewport viewport ) : base( parent )
{
_viewport = viewport;
Name = "Lights";
WindowTitle = "Lights";
SetWindowIcon( "lightbulb" );
Layout = Layout.Column();
Layout.Add( RigHelpBox.Create( this,
"Lighting for the viewport, saved on this clip. A clip with no lights uses the " +
"built-in sun and ambient; add one and these replace them.",
new[]
{
RigHelpBox.S( "Why bother",
"A pose reads by its shadows. Under one fixed light an arm in front of a " +
"chest is a flat shape and a hand turned over looks identical either way - " +
"so a second light from another angle is often the difference between " +
"judging a pose and guessing at it." ),
RigHelpBox.S( "Place At Camera",
"Puts the selected light where you are looking from, aimed the way you are " +
"looking. Fly the viewport to where you want the light, click the button. " +
"That is faster than any three numbers, and it is how a key light gets " +
"placed on a real set." ),
RigHelpBox.S( "Export With Clip",
"Off means the light is workspace - this window only, never in a game. On " +
"means RigAnimPlayerComponent spawns it beside the model when the clip " +
"plays, which is how a lamp authored with the pose it lights travels with " +
"it. Either way it cannot go into an exported .vmdl: that file is bone " +
"channels and has nowhere to put a light." ),
RigHelpBox.S( "Start from the defaults",
"Add ▸ Copy Default Lighting drops the built-in sun and ambient in as two " +
"ordinary entries. Adjusting those is usually quicker than lighting from " +
"black, and it makes it obvious what you changed." ),
} ) );
var bar = Layout.AddRow();
bar.Margin = 4;
bar.Add( new Editor.Label( "Lights" ), 1 );
bar.Add( new Button( "Add Light", "add" ) { Clicked = OpenAddMenu } );
var scroll = Layout.Add( new ScrollArea( this ), 1 );
scroll.VerticalScrollbarMode = ScrollbarMode.Auto;
scroll.HorizontalScrollbarMode = ScrollbarMode.Off;
_list = new Widget( this ) { Layout = Layout.Column() };
_list.Layout.Margin = 4;
_list.Layout.Spacing = 4;
scroll.Canvas = _list;
Rebuild();
}
public void SetAnim( RigAnimDocument anim )
{
_anim = anim;
Rebuild();
}
private void OpenAddMenu()
{
if ( _anim is null )
return;
var menu = new Menu( this );
menu.AddOption( "Directional", "wb_sunny", () => Add( RigLightKind.Directional ) );
menu.AddOption( "Point", "lightbulb", () => Add( RigLightKind.Point ) );
menu.AddOption( "Spot", "flashlight_on", () => Add( RigLightKind.Spot ) );
menu.AddOption( "Ambient", "wb_twilight", () => Add( RigLightKind.Ambient ) );
// Only worth offering while the defaults are still what you are looking at - once the
// list has entries the defaults are already off, and this would read as "put them back"
// when what it does is add two more lights.
if ( Lights.Count == 0 )
{
menu.AddSeparator();
menu.AddOption( "Copy Default Lighting", "content_copy", CopyDefaults );
}
menu.OpenAtCursor();
}
private List<RigLight> Lights => _anim?.Lights ??= new List<RigLight>();
private void Add( RigLightKind kind )
{
var light = new RigLight { Kind = kind, Name = UniqueName( kind.ToString() ) };
// A new point or spot lands where you are looking from rather than at the origin, which
// for a light is inside the model. Nobody wants their first act after adding a light to
// be dragging it out of somebody's chest.
if ( kind is RigLightKind.Point or RigLightKind.Spot )
PlaceAtCamera( light );
Lights.Add( light );
Rebuild();
Changed?.Invoke( "Add Light" );
}
/// <summary>The built-in lighting as two ordinary entries, so it can be adjusted rather than
/// replaced. The numbers are RigViewport.DefaultLighting's, and the ambient is resolved from
/// the theme HERE rather than left as a live reference - a clip that stores its lighting has
/// to store a colour, not a promise to ask the editor's theme again later.</summary>
private void CopyDefaults()
{
Lights.Add( new RigLight
{
Name = "sun",
Kind = RigLightKind.Directional,
Rotation = new Angles( 45f, 45f, 0f ),
Color = Color.White,
} );
Lights.Add( new RigLight
{
Name = "ambient",
Kind = RigLightKind.Ambient,
Color = Theme.ControlBackground * 0.6f,
} );
Rebuild();
Changed?.Invoke( "Copy Default Lighting" );
}
private string UniqueName( string prefix )
{
var existing = Lights.Select( l => l?.Name ).ToHashSet();
var index = 0;
while ( existing.Contains( $"{prefix} {index}" ) )
index++;
return $"{prefix} {index}";
}
public void Rebuild()
{
_list.Layout.Clear( true );
if ( _anim is null )
{
_list.Layout.Add( new Editor.Label( "Open a Rig Animation asset to light it." ) { Enabled = false } );
return;
}
foreach ( var light in Lights.Where( l => l is not null ).ToList() )
_list.Layout.Add( Build( light ) );
if ( Lights.Count == 0 )
{
_list.Layout.Add( new Editor.Label(
"No lights - this clip is using the built-in sun and ambient. Add one and these replace them." )
{ Enabled = false } );
}
}
private Widget Build( RigLight light )
{
var panel = new Widget( _list ) { Layout = Layout.Column() };
panel.Layout.Margin = 6;
panel.Layout.Spacing = 4;
var header = panel.Layout.AddRow();
header.Spacing = 6;
var chip = new Editor.Label( $"[{light.Kind}]" ) { Color = ChipColor( light.Kind ) };
chip.SetStyles( "font-weight: bold;" );
header.Add( chip );
header.Add( new Editor.Label( light.Name ) { Enabled = false }, 1 );
// The one badge worth having on the header: whether this light leaves the window. It is
// the difference between a working light and something that will show up in a game, and
// it is not worth scrolling a property sheet to find out.
if ( light.Export )
{
var exported = new Editor.Label( "exports" ) { Color = Theme.Green };
exported.SetStyles( "font-weight: bold;" );
header.Add( exported );
}
var serialized = EditorTypeLibrary.GetSerializedObject( light );
// Rebuild, not just refresh: Kind decides which fields the sheet shows, and the name and
// the export badge are drawn in the header above it. Any of the three changing means
// this row is out of date, and a light whose sheet disagrees with its own header is
// worse than a moment's flicker.
serialized.OnPropertyChanged += _ =>
{
Changed?.Invoke( "Edit Light" );
Rebuild();
};
var sheet = new ControlSheet();
sheet.AddObject( serialized );
panel.Layout.Add( sheet );
panel.Layout.Add( Buttons( light ) );
return panel;
}
private Widget Buttons( RigLight light )
{
var row = new Widget { Layout = Layout.Row() };
row.Layout.Spacing = 4;
var toggle = new Button( light.Enabled ? "On" : "Off", light.Enabled ? "toggle_on" : "toggle_off" )
{
ToolTip = "Turn this light off without losing it - the way to see what one light is doing"
};
toggle.Clicked = () =>
{
light.Enabled = !light.Enabled;
toggle.Text = light.Enabled ? "On" : "Off";
toggle.Icon = light.Enabled ? "toggle_on" : "toggle_off";
Changed?.Invoke( "Toggle Light" );
};
row.Layout.Add( toggle );
// Not offered for the two kinds that have nowhere to put a camera position: a directional
// light has no position, and an ambient has neither position nor direction. A button that
// does nothing on half the rows teaches people to distrust it.
if ( light.Kind is RigLightKind.Point or RigLightKind.Spot or RigLightKind.Directional )
{
row.Layout.Add( new Button( "Place At Camera", "photo_camera" )
{
ToolTip = "Move this light to the viewport camera, aimed where you are looking",
Clicked = () =>
{
PlaceAtCamera( light );
Rebuild();
Changed?.Invoke( "Place Light" );
}
} );
}
row.Layout.AddStretchCell();
row.Layout.Add( new Button( "Delete", "delete" )
{
Clicked = () =>
{
Lights.Remove( light );
Rebuild();
Changed?.Invoke( "Delete Light" );
}
} );
return row;
}
/// <summary>Where you are looking from, aimed where you are looking. A directional light
/// takes only the angle - it has no position to take.</summary>
private void PlaceAtCamera( RigLight light )
{
var view = _viewport?.ViewCamera ?? Transform.Zero;
light.Rotation = view.Rotation.Angles();
if ( light.Kind != RigLightKind.Directional )
light.Position = view.Position;
}
private static Color ChipColor( RigLightKind kind ) => kind switch
{
RigLightKind.Directional => Theme.Yellow,
RigLightKind.Point => Theme.Blue,
RigLightKind.Spot => Theme.Green,
_ => Theme.TextControl,
};
}