Rendering/SceneLook.cs

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.

Reflection
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 &lt;key&gt; &lt;ambient&gt; &lt;contactShadows&gt;</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;
		}
	}
}