Files
sbox-public/engine/Sandbox.Engine/Scene/GameObjectSystems/CameraEffectSystem.cs
Garry Newman ca96c2a945 Make Inventory, BaseWeapon, AmmoResource, Screenshake first party (#5245)
https://sbox.game/dev/doc/gameplay/inventory-weapons/

Added

InventoryComponent - a slot based inventory, hotbar or shared bucket slots, with a starting loadout
BaseInventoryItem - base class for inventory items, with hooks to veto switching, holstering and dropping
BaseWeapon - a GMod style base weapon - fire modes, magazines and reserve ammo, reloading, dry fire, ballistics, spread, crosshair and HUD
BaseWeaponModel - view/world model base with muzzle flash, brass ejection, tracers and animation hooks
Ammo types are resources (.ammo) - weapons share reserve pools by type, clamped to a max reserve
BaseWeapon.ShootBullets / ShootBullet - bullet tracing with damage, impulse and hitbox tags
Weapon hold types editor
Animgraph enum parameters can be set by option name - Renderer.Set( "holdtype", "pistol" )
NpcUsage on BaseWeapon - engagement range and burst behaviour so NPCs can use any weapon
ICameraModifier - cameras compose their view through an ordered modifier chain, read the result from CameraComponent.View
CameraEffectSystem - screen shake, directional punches and tilts, with world epicenter falloff - camera.AddShake / AddPunch / AddTilt
EnvShake component - shakes the view, rumbles controllers, kicks physics
TracerEffect - an animated tracer line, ITargetedEffect for effects that fly at a target
SceneAnchor - a world position that can follow an object or bone
SkinnedModelRenderer.GetClosestBone( worldPosition )
Vector3.WithAimCone can take a custom Random for reproducible spread
Improve/Fix

PlayerController searches parents for IPressable when pressing
PlayerController camera runs through the modifier chain - IEvents.PostCameraSetup is obsolete
SoundPlayer preview uses shortcuts - Space toggles playback, Ctrl+Space restarts
Fixed Radius( 0 ) traces missing everything
Fixed NRE in AssetPreviewWidget
Removed

Removed CameraComponent.ISceneCameraSetup (obsolete since October, nothing uses)
2026-07-04 12:46:59 +01:00

290 lines
11 KiB
C#

namespace Sandbox;
/// <summary>
/// Transient camera effects - screen shakes, punches and tilts. Fire and forget:
/// <c>CameraEffectSystem.Current.AddShake( Scene.Camera, 4, 40, 1 )</c>. The camera folds the
/// combined offsets in when it composes its view each tick - effects never touch the camera's
/// transform or the player's aim, so they can't accumulate or fight the game's camera code.
/// </summary>
public sealed class CameraEffectSystem : GameObjectSystem<CameraEffectSystem>
{
/// <summary>
/// Global multiplier on every camera effect - wire this to a screen shake preference. Zero
/// disables them entirely.
/// </summary>
public float Scale { get; set; } = 1.0f;
readonly List<BaseEffect> _effects = [];
public CameraEffectSystem( Scene scene ) : base( scene )
{
Listen( Stage.StartUpdate, 0, Tick, "TickCameraEffects" );
}
void Tick()
{
foreach ( var effect in _effects )
{
effect.Update( Time.Delta );
}
_effects.RemoveAll( x => x.IsDone );
}
/// <summary>
/// Add a camera effect. A null <see cref="BaseEffect.Camera"/> applies to every camera.
/// </summary>
public T Add<T>( T effect ) where T : BaseEffect
{
_effects.Add( effect );
return effect;
}
/// <summary>Remove every active effect.</summary>
public void Clear() => _effects.Clear();
/// <summary>
/// The classic screen shake (Half-Life's env_shake) - every 1/<paramref name="frequency"/> seconds
/// a new random offset within <paramref name="amplitude"/> world units, settling to nothing over
/// <paramref name="duration"/>. A duration of zero shakes until <see cref="BaseEffect.Stop"/>.
/// </summary>
public ScreenShake AddShake( CameraComponent camera, float amplitude, float frequency, float duration )
{
return Add( new ScreenShake { Camera = camera, Amplitude = amplitude, Frequency = frequency, Duration = duration } );
}
/// <summary>
/// A world shake with distance falloff - full <paramref name="amplitude"/> at
/// <paramref name="position"/> fading to nothing at <paramref name="radius"/>. Affects every
/// camera in range. Use for explosions.
/// </summary>
public ScreenShake AddShake( Vector3 position, float radius, float amplitude, float frequency, float duration )
{
return Add( new ScreenShake { Epicenter = position, Radius = radius, Amplitude = amplitude, Frequency = frequency, Duration = duration } );
}
/// <summary>
/// A punch along a camera-local direction that oscillates and dies out - <see cref="Vector3.Up"/>
/// bounces the view upward, <paramref name="frequency"/> scales how often it oscillates before
/// it lapses.
/// </summary>
public BaseEffect AddPunch( CameraComponent camera, Vector3 direction, float amplitude, float frequency = 1f, float duration = 0.3f, float fovAmplitude = 0f )
{
return Add( new ScreenPunch { Camera = camera, Direction = direction, Amplitude = amplitude, Frequency = frequency, Duration = duration, FovAmplitude = fovAmplitude } );
}
/// <summary>
/// An angular punch - the view kicks to <paramref name="angles"/> and bounces back,
/// <paramref name="frequency"/> scaling how often it oscillates before it lapses. Melee swings,
/// landings, launches.
/// </summary>
public BaseEffect AddPunch( CameraComponent camera, Angles angles, float frequency = 1f, float duration = 0.3f, float fovAmplitude = 0f )
{
return Add( new ScreenPunch { Camera = camera, Amplitude = 0f, AngleAmplitude = angles, Frequency = frequency, Duration = duration, FovAmplitude = fovAmplitude } );
}
/// <summary>
/// Tilt the camera by <paramref name="angle"/>, eased in over <paramref name="easeTime"/> and back
/// out before <paramref name="duration"/> ends.
/// </summary>
public BaseEffect AddTilt( CameraComponent camera, Angles angle, float duration, float easeTime = 0.2f )
{
return Add( new ScreenTilt { Camera = camera, Angle = angle, Duration = duration, EaseTime = easeTime } );
}
/// <summary>
/// The combined effect offsets for this camera this frame - the position is camera-local
/// (<see cref="Vector3.Forward"/> pushes the view forward). The camera reads these once per
/// tick, when it composes its view.
/// </summary>
public void QueryOffsets( CameraComponent camera, out Vector3 position, out Angles angles, out float fieldOfView )
{
position = default;
angles = default;
fieldOfView = 0f;
if ( _effects.Count == 0 || Scale <= 0f )
return;
foreach ( var effect in _effects )
{
var scale = effect.ScaleFor( camera ) * Scale;
if ( scale <= 0f )
continue;
effect.Evaluate( scale, ref position, ref angles, ref fieldOfView );
}
}
/// <summary>
/// A transient camera effect, living in the <see cref="CameraEffectSystem"/>. Targets one
/// <see cref="Camera"/>, every camera (null), or radiates from an <see cref="Epicenter"/> with
/// distance falloff. Derive to make your own effects.
/// </summary>
public abstract class BaseEffect
{
/// <summary>The camera this applies to - null applies to every camera.</summary>
public CameraComponent Camera { get; set; }
/// <summary>
/// When set, the effect radiates from this world position - full strength at the epicenter,
/// nothing beyond <see cref="Radius"/>.
/// </summary>
public Vector3? Epicenter { get; set; }
/// <summary>How far the effect reaches from <see cref="Epicenter"/>.</summary>
public float Radius { get; set; } = 512f;
/// <summary>Seconds the effect runs. Zero or less runs until <see cref="Stop"/>.</summary>
public float Duration { get; set; } = 1f;
/// <summary>Seconds this effect has been running.</summary>
public float TimeAlive { get; private set; }
/// <summary>Runs until <see cref="Stop"/> rather than expiring.</summary>
public bool IsInfinite => Duration <= 0f;
bool _stopped;
/// <summary>
/// True when the effect has expired or been stopped, and is about to be removed. An effect
/// whose target camera has been destroyed is done too.
/// </summary>
public virtual bool IsDone => _stopped || (!IsInfinite && TimeAlive > Duration) || (Camera is not null && !Camera.IsValid());
/// <summary>End the effect now.</summary>
public void Stop() => _stopped = true;
/// <summary>1 at the start of the effect's life falling to 0 at the end. Infinite effects hold 1.</summary>
protected float Fraction => IsInfinite ? 1f : (1f - TimeAlive / Duration).Clamp( 0f, 1f );
/// <summary>
/// Advance the effect - called once a frame by the system.
/// </summary>
public virtual void Update( float delta )
{
TimeAlive += delta;
}
/// <summary>
/// How strongly this effect applies to a camera - 0 when it targets a different camera, fading
/// with distance from the <see cref="Epicenter"/> when one is set. Override for custom falloff.
/// </summary>
public virtual float ScaleFor( CameraComponent camera )
{
// A null target means every camera - a destroyed one doesn't.
if ( Camera is not null && Camera != camera )
return 0f;
if ( Epicenter is { } epicenter )
return 1f - (camera.WorldPosition.Distance( epicenter ) / MathF.Max( Radius, 1f )).Clamp( 0f, 1f );
return 1f;
}
/// <summary>
/// Accumulate this effect's contribution. <paramref name="position"/> is camera-local -
/// <see cref="Vector3.Forward"/> pushes the view forward, <see cref="Vector3.Up"/> up.
/// </summary>
public abstract void Evaluate( float scale, ref Vector3 position, ref Angles angles, ref float fieldOfView );
}
/// <summary>
/// The classic screen shake - a new random offset every 1/frequency seconds, blended by a
/// squared-falloff sine envelope whose frequency ramps up as it dies, amplitude decaying as it
/// goes. Position plus a little roll, never pitch or yaw. Half-Life's env_shake, byte for byte.
/// </summary>
public sealed class ScreenShake : BaseEffect
{
/// <summary>How far the view gets thrown, in world units. Settable while the shake runs.</summary>
public float Amplitude { get; set; } = 4f;
/// <summary>Direction changes per second - low is a slow lurch, high is a violent tremble.</summary>
public float Frequency { get; set; } = 40f;
float _untilNextShake;
Vector3 _offset;
float _roll;
public override void Update( float delta )
{
base.Update( delta );
_untilNextShake -= delta;
if ( _untilNextShake <= 0f )
{
_untilNextShake = 1f / MathF.Max( Frequency, 0.01f );
_offset = System.Random.Shared.VectorInCube( Amplitude );
_roll = System.Random.Shared.Float( -Amplitude * 0.25f, Amplitude * 0.25f );
}
// Settle a little every frame - less for higher frequency shakes.
if ( !IsInfinite )
Amplitude -= Amplitude * delta / (Duration * MathF.Max( Frequency, 0.01f ));
}
public override void Evaluate( float scale, ref Vector3 position, ref Angles angles, ref float fieldOfView )
{
var fraction = Fraction;
// The envelope falls off squared while its frequency ramps up - the classic settle.
var frequency = fraction > 0f ? Frequency / fraction : 0f;
fraction *= fraction * MathF.Sin( Time.Now * frequency );
position += _offset * (fraction * scale);
angles.roll += _roll * fraction * scale;
}
}
/// <summary>
/// A punch that oscillates and lapses out - <c>sin( t·3π·frequency )·(1 - t)</c> over the
/// duration, along a camera-local direction and/or an angular kick, with an optional FOV punch.
/// </summary>
sealed class ScreenPunch : BaseEffect
{
public Vector3 Direction { get; set; } = Vector3.Up;
public float Amplitude { get; set; } = 4f;
public float Frequency { get; set; } = 1f;
public Angles AngleAmplitude { get; set; }
public float FovAmplitude { get; set; } = 0f;
public override void Evaluate( float scale, ref Vector3 position, ref Angles angles, ref float fieldOfView )
{
// y = sin( f·x )·(1 - x/3π), the lifetime mapped over 0..3π.
var t = 1f - Fraction;
var y = MathF.Sin( t * 3f * MathF.PI * Frequency ) * Fraction;
position += Direction * (Amplitude * y * scale);
angles += AngleAmplitude * (y * scale);
fieldOfView += FovAmplitude * y * scale;
}
}
/// <summary>
/// Tilts the camera to an angle and back - eased in over EaseTime, held, then eased back out
/// before the duration ends.
/// </summary>
sealed class ScreenTilt : BaseEffect
{
public Angles Angle { get; set; } = new( 0f, 0f, 15f );
public float EaseTime { get; set; } = 0.2f;
public override void Evaluate( float scale, ref Vector3 position, ref Angles angles, ref float fieldOfView )
{
var ease = MathF.Max( EaseTime, 0.001f );
var interp = TimeAlive / ease;
// Ease back out as the end approaches.
if ( !IsInfinite )
interp = MathF.Min( interp, (Duration - TimeAlive) / ease );
interp = Sandbox.Utility.Easing.SineEaseInOut( interp.Clamp( 0f, 1f ) );
angles += Angle * (interp * scale);
}
}
}