Code/GolfLensFlare.cs

A component that renders a camera-facing lens flare sprite on a child of a lamp. It computes angle between the lamp beam and the active/editor camera, fades opacity between FullAngle and FadeAngle, sizes the quad in world space to achieve a desired screen pixel size, and updates a ModelRenderer each frame.

Native Interop
using Sandbox;
using System;

/// <summary>
/// A lens flare for the lighthouse lamp: a soft radial sprite on a camera-facing quad at the
/// lamp, drawn only when the rotating beam points at this machine's camera. Full size and
/// opacity when the beam is within <see cref="FullAngle"/> degrees of the camera, nothing beyond
/// <see cref="FadeAngle"/>, so the flash sweeps past each player as the lamp turns. The beam
/// direction is the parent object's forward (the SpotLight's), so this sits on a child of the
/// light. Runs in the editor too, where the editor camera stands in for the player's.
/// </summary>
[Title( "Golf Lens Flare" ), Icon( "flare" ), Category( "Golf" )]
public sealed class GolfLensFlare : Component, Component.ExecuteInEditor
{
	/// <summary>Smallest the flare gets on screen, in pixels, however far the lamp is.</summary>
	[Property, Title( "Flare Size" ), Range( 5f, 300f )]
	public float FlareSize { get; set; } = 40f;

	/// <summary>Opacity at full strength, 0 to 1.</summary>
	[Property, Range( 0f, 1f )]
	public float MaxOpacity { get; set; } = 0.6f;

	/// <summary>On-screen size in pixels when the lamp is 1000 units away; it grows gently closer in and shrinks farther out.</summary>
	[Property, Range( 10f, 300f )]
	public float ScreenSizeAt1000 { get; set; } = 60f;

	/// <summary>The flare never covers more than this many pixels on screen, however close the camera is.</summary>
	[Property, Range( 20f, 400f )]
	public float MaxScreenSize { get; set; } = 120f;

	/// <summary>Within this many degrees of the camera the flare is at full strength.</summary>
	[Property, Range( 0f, 45f )]
	public float FullAngle { get; set; } = 5f;

	/// <summary>Beyond this many degrees off the camera the flare is gone.</summary>
	[Property, Range( 1f, 90f )]
	public float FadeAngle { get; set; } = 25f;

	const string PlaneModel = "models/dev/plane.vmdl";
	const string FlareMaterial = "textures/lighthouse/lighthouse_flare.vmat";

	ModelRenderer _renderer;

	protected override void OnEnabled()
	{
		_renderer = GetOrAddComponent<ModelRenderer>();
		_renderer.Model ??= Model.Load( PlaneModel );
		_renderer.MaterialOverride ??= Material.Load( FlareMaterial );
		_renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
	}

	protected override void OnUpdate()
	{
		if ( !_renderer.IsValid() ) return;

		var camera = Scene.Camera;
		if ( !camera.IsValid() )
		{
			_renderer.Enabled = false;
			return;
		}

		var toCamera = camera.WorldPosition - WorldPosition;
		var distance = toCamera.Length;
		if ( distance < 1f )
		{
			_renderer.Enabled = false;
			return;
		}
		toCamera /= distance;

		// How far off the camera the beam points, in degrees.
		var beam = GameObject.Parent.IsValid() ? GameObject.Parent.WorldRotation.Forward : WorldRotation.Forward;
		var angle = MathF.Acos( Math.Clamp( beam.Dot( toCamera ), -1f, 1f ) ).RadianToDegree();
		var strength = 1f - Math.Clamp( (angle - FullAngle) / MathF.Max( 0.01f, FadeAngle - FullAngle ), 0f, 1f );

		if ( strength <= 0f || MaxOpacity <= 0f )
		{
			_renderer.Enabled = false;
			return;
		}

		_renderer.Enabled = true;
		_renderer.Tint = Color.White.WithAlpha( MaxOpacity * strength );

		// Face the camera from the lamp, at a size set on screen, not in the world: ScreenSizeAt1000
		// pixels at 1000 units, scaling with the square root of the distance (a touch bigger near,
		// a touch smaller far), between FlareSize and MaxScreenSize pixels. One pixel spans a known
		// number of units at this distance, from the camera's vertical field of view.
		LocalPosition = Vector3.Zero;
		WorldRotation = Rotation.LookAt( toCamera ) * Rotation.FromPitch( 90f );
		var extent = _renderer.Model?.Bounds.Size.x ?? 100f;
		if ( extent < 1f ) extent = 100f;
		var verticalFov = Screen.CreateVerticalFieldOfView( camera.FieldOfView );
		var unitsPerPixel = 2f * distance * MathF.Tan( verticalFov.DegreeToRadian() * 0.5f ) / MathF.Max( 1f, Screen.Height );
		var pixels = Math.Clamp( ScreenSizeAt1000 * MathF.Sqrt( 1000f / MathF.Max( 1f, distance ) ), FlareSize, MaxScreenSize );
		var world = pixels * unitsPerPixel * (0.7f + 0.3f * strength);
		var size = world / extent;
		LocalScale = new Vector3( size, size, size );
	}
}