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