Component that centralises scene lighting and post-processing settings. It finds/assigns key, fill and ambient lights and a camera, applies rotations, colours, shadow settings and post effects (vignette, color grade, sharpen, tonemapping) on enable or when requested, and exposes a console command to tweak key/ambient/exposure at runtime.
namespace Coilgarden;
/// <summary>
/// Drives the scene's lighting and post-processing from <see cref="Palette"/> in code.
/// <para>
/// The look used to be authored on the scene's components. That was wrong for two reasons.
/// The first is architectural: the project's rule is that tuning values live in one file, and
/// having the entire visual identity spread across component properties in a JSON scene made
/// it the one part of the game that could not be read, reviewed or diffed. The second is
/// practical: the scene is only editable through the editor, play mode runs a <em>clone</em> of
/// it, and the views only build in <c>OnUpdate</c> - so every colour change cost a save, a play
/// restart and a round trip, and half of them silently edited the wrong copy.
/// </para>
/// <para>
/// Applied <b>once</b>, on enable, and never per frame. An earlier version wrote everything in
/// <c>OnPreRender</c> to get instant hot-reload feedback, and that rendered the entire frame
/// black - re-writing exposure and light state every frame fights whatever the renderer is
/// doing between frames. Play mode is restarted for every visual change anyway, so applying on
/// enable loses nothing.
/// </para>
/// <para>
/// It also deliberately does <b>not</b> touch <see cref="Tonemapping"/>. Exposure is left to
/// the values authored on the scene; driving it from here was part of what caused the black
/// frame, and it is the one setting where the engine's own adaptation needs to be left alone.
/// </para>
/// </summary>
public sealed class SceneLook : Component
{
/// <summary>
/// Shadow bias. Emphatically not zero: at zero every surface shadows itself and the whole
/// scene renders black, which cost a full debugging cycle to find. It looks exactly like
/// "the lights are not working".
/// </summary>
public const float ShadowBias = 0.08f;
public const float ShadowHardness = 0.85f;
/// <summary>Key light heading, as pitch/yaw. Comes from above and to the left of the viewer.</summary>
public static readonly Angles KeyAngles = new( 22f, -20f, 0f );
/// <summary>Fill light heading. Opposite side, shallower, so it fills without flattening.</summary>
public static readonly Angles FillAngles = new( -24f, 30f, 0f );
/// <summary>
/// The vignette is off.
/// <para>
/// It banded visibly into concentric rings against the near-black backdrop at every
/// intensity that was strong enough to be worth having, and at that point it was adding an
/// artefact rather than atmosphere. The dark backdrop already frames the tray, which is all
/// the vignette was there to do. Worth revisiting in the polish pass with a lighter
/// backdrop to dither against.
/// </para>
/// </summary>
public const bool EnableVignette = false;
/// <summary>
/// Lit surfaces come back noticeably duller than their albedo, so a little saturation is
/// added back at the end rather than by pushing the palette - channels above 1 wrap.
/// </summary>
public const float Saturation = 1.24f;
public const float Contrast = 1.04f;
/// <summary>Lifts the whole image slightly; tonemapping lands the palette darker than authored.</summary>
public const float Brightness = 1.56f;
public const float SharpenScale = 0.18f;
// ---------------------------------------------------------------- live tunables
// Static rather than const so `cg_light` can drive them without a play restart. Lighting is
// the one part of this game that cannot be judged by reasoning, and a restart per experiment
// made iterating on it prohibitively slow.
/// <summary>
/// Multiplier on the key light. Above 1 the sand comes back closer to the pale cream it is
/// authored as - a lit surface returns well below its albedo, and the floor was reading as a
/// dull greige rather than as sand.
/// </summary>
public static float KeyScale = 1f;
public static float FillScale = 1f;
/// <summary>
/// Multiplier on the ambient wash. Below 1 deepens shadows; the original value lit every
/// surface from all sides hard enough that nothing cast a shadow worth seeing.
/// </summary>
public static float AmbientScale = 1f;
/// <summary>
/// Off, having been tried and made no visible difference.
/// <para>
/// Screen-space contact shadows are exactly the feature the missing shadow under each piece
/// calls for, and enabling them changed nothing that could be seen at any lighting balance -
/// so it is GPU cost bought for nothing. Left here as a switch because it is the first thing
/// worth re-testing if the lighting is ever reworked.
/// </para>
/// </summary>
public static bool UseContactShadows = false;
/// <summary>
/// Fixed exposure for the tonemapper. This is the only global brightness control that
/// actually moves the image: the sand's albedo is already at the legal ceiling and a lit
/// surface still comes back well under it, because the filmic curve compresses everything
/// below white. Light intensity cannot reach past that; exposure can.
/// </summary>
public static float Exposure = 1f;
/// <summary>
/// A light colour at a given intensity.
/// <para>
/// Channels are allowed past 1 here, unlike anywhere else in this project. The wrap-above-1
/// hazard recorded in the conventions is a property of the <em>tint</em> path on a
/// <see cref="ModelRenderer"/>, which truncates through 8 bits per channel; a light's colour
/// is consumed as a float and scaling it is the only way to actually add intensity rather
/// than merely desaturate towards white.
/// </para>
/// </summary>
private static Color Scaled( Color colour, float scale ) =>
new( colour.r * scale, colour.g * scale, colour.b * scale, colour.a );
[Property] public DirectionalLight KeyLight { get; set; }
[Property] public DirectionalLight FillLight { get; set; }
[Property] public AmbientLight Ambient { get; set; }
[Property] public CameraComponent Camera { get; set; }
/// <summary>Turn the whole grade off, to check the game still reads without it.</summary>
[Property] public bool EnablePost { get; set; } = true;
private Vignette vignette;
private ColorAdjustments grade;
private Sharpen sharpen;
private Tonemapping tonemapping;
/// <summary>
/// The live look, so the lighting can be re-applied from a console command. Lighting is the
/// one thing in this project that cannot be judged without looking at it, and a play restart
/// per experiment makes iterating on it prohibitively slow.
/// </summary>
public static SceneLook Current { get; private set; }
protected override void OnDisabled()
{
if ( Current == this ) Current = null;
}
/// <summary>Re-applies lighting and grade. Safe to call at any time; never per frame.</summary>
public void Apply()
{
ApplyLighting();
ApplyPost();
}
protected override void OnEnabled()
{
Current = this;
Camera ??= Components.Get<CameraComponent>();
var lights = Scene.GetAllComponents<DirectionalLight>().ToList();
// Identified by object name, not by which one currently casts shadows. Picking the key
// by `l.Shadows` was circular: this component is what turns shadows on, so before it had
// ever run the test could match nothing and silently hand the key's warm colour and
// angle to the fill light - swapping the two whenever the scene was in the wrong state.
KeyLight ??= Find( lights, "Key" ) ?? lights.FirstOrDefault();
FillLight ??= Find( lights, "Fill" ) ?? lights.FirstOrDefault( l => l != KeyLight );
Ambient ??= Scene.GetAllComponents<AmbientLight>().FirstOrDefault();
vignette = Components.Get<Vignette>();
grade = Components.Get<ColorAdjustments>();
sharpen = Components.Get<Sharpen>();
tonemapping = Components.Get<Tonemapping>();
ApplyLighting();
ApplyPost();
}
/// <summary>
/// Retunes the lighting live: <c>cg_light <key> <ambient> <contactShadows></c>.
/// <para>
/// Development only. It exists because lighting cannot be judged without looking at it, and
/// applying on enable alone means a play restart for every experiment.
/// </para>
/// </summary>
[ConCmd( "cg_light" )]
public static void Light( float key, float ambient, float exposure )
{
if ( Current is null )
{
Log.Info( "cg_light: no SceneLook in the scene. Is play mode started?" );
return;
}
KeyScale = key.Clamp( 0.1f, 6f );
AmbientScale = ambient.Clamp( 0f, 3f );
Exposure = exposure.Clamp( 0.1f, 6f );
Current.Apply();
Log.Info( $"cg_light: key={KeyScale:F2} ambient={AmbientScale:F2} exposure={Exposure:F2} " +
$"contactShadows={UseContactShadows}" );
}
private static DirectionalLight Find( List<DirectionalLight> lights, string nameContains ) =>
lights.FirstOrDefault( l =>
l.GameObject.IsValid() &&
l.GameObject.Name.Contains( nameContains, StringComparison.OrdinalIgnoreCase ) );
private void ApplyLighting()
{
if ( Camera.IsValid() ) Camera.BackgroundColor = Palette.Backdrop;
if ( KeyLight.IsValid() )
{
KeyLight.WorldRotation = KeyAngles;
KeyLight.LightColor = Scaled( Palette.KeyLight, KeyScale );
// This is a *second* ambient term on top of the AmbientLight component. Running both
// was suspected of being why nothing casts a visible shadow, but removing it was
// tried and made the image plainly worse - the tray went dull and the whole frame
// shifted red, with the shadows no more visible than before. It stays.
KeyLight.SkyColor = Scaled( Palette.Ambient, AmbientScale );
KeyLight.Shadows = true;
KeyLight.ShadowBias = ShadowBias;
KeyLight.ShadowHardness = ShadowHardness;
// The cascaded shadow map is far too coarse for a sphere resting on a flat tray;
// contact shadows are the screen-space pass that catches exactly that detail, and
// it is what puts the pieces *on* the sand rather than floating above it.
KeyLight.ContactShadows = UseContactShadows;
}
if ( FillLight.IsValid() )
{
FillLight.WorldRotation = FillAngles;
FillLight.LightColor = Scaled( Palette.FillLight, FillScale );
// Black, so the fill adds direction without also adding another ambient term.
FillLight.SkyColor = Color.Black;
// One shadow-casting light. A second set of shadows on a top-down board reads as
// dirt rather than as depth. Contact shadows off here for the same reason.
FillLight.Shadows = false;
FillLight.ContactShadows = false;
}
if ( Ambient.IsValid() ) Ambient.Color = Scaled( Palette.Ambient, AmbientScale );
// Framing is driven from here too, so GameConfig is the single source of truth for it.
// A value authored on the scene component otherwise wins over the code default, which
// is how a padding change can appear to do nothing at all.
var framing = Components.Get<ArenaCamera>();
if ( framing.IsValid() )
{
framing.Padding = GameConfig.CameraPadding;
framing.CellSize = GameConfig.CellSize;
framing.Distance = GameConfig.CameraDistance;
}
}
private void ApplyPost()
{
if ( vignette.IsValid() ) vignette.Enabled = EnablePost && EnableVignette;
// Pinned rather than adapting. Auto exposure on a board whose contents change brightness
// as the snake grows would make the tray subtly breathe, and a fixed value is the one
// control that can actually lift the sand to the cream it is authored as. Written once
// on enable, never per frame - driving exposure every frame renders the whole scene
// black.
if ( tonemapping.IsValid() )
{
tonemapping.AutoExposureEnabled = false;
tonemapping.MinimumExposure = Exposure;
tonemapping.MaximumExposure = Exposure;
tonemapping.ExposureCompensation = 0f;
}
if ( grade.IsValid() )
{
grade.Enabled = EnablePost;
grade.Blend = 1f;
grade.Saturation = Saturation;
grade.Contrast = Contrast;
grade.Brightness = Brightness;
grade.HueRotate = 0f;
}
if ( sharpen.IsValid() )
{
sharpen.Enabled = EnablePost;
sharpen.Scale = SharpenScale;
}
}
}