worldHandle.SetTransform(localMatrix);
worldHandle.DrawTextureRectRegion(lightOverlay.EnlargedLightTarget.Texture, bounds, subRegion: subRegion);
- }, null);
+ }, Color.Transparent);
}
}
_overlayMan.AddOverlay(new RoofOverlay(EntityManager));
_overlayMan.AddOverlay(new TileEmissionOverlay(EntityManager));
_overlayMan.AddOverlay(new LightBlurOverlay());
+ _overlayMan.AddOverlay(new SunShadowOverlay());
_overlayMan.AddOverlay(new AfterLightTargetOverlay());
}
_overlayMan.RemoveOverlay<RoofOverlay>();
_overlayMan.RemoveOverlay<TileEmissionOverlay>();
_overlayMan.RemoveOverlay<LightBlurOverlay>();
+ _overlayMan.RemoveOverlay<SunShadowOverlay>();
_overlayMan.RemoveOverlay<AfterLightTargetOverlay>();
}
}
--- /dev/null
+using System.Diagnostics.Contracts;
+using System.Numerics;
+using Content.Client.GameTicking.Managers;
+using Content.Shared.Light.Components;
+using Content.Shared.Light.EntitySystems;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Client.Light.EntitySystems;
+
+public sealed class SunShadowSystem : SharedSunShadowSystem
+{
+ [Dependency] private readonly ClientGameTicker _ticker = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly MetaDataSystem _metadata = default!;
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ if (!_timing.IsFirstTimePredicted)
+ return;
+
+ var mapQuery = AllEntityQuery<SunShadowCycleComponent, SunShadowComponent>();
+ while (mapQuery.MoveNext(out var uid, out var cycle, out var shadow))
+ {
+ if (!cycle.Running || cycle.Directions.Count == 0)
+ continue;
+
+ var pausedTime = _metadata.GetPauseTime(uid);
+
+ var time = (float)(_timing.CurTime
+ .Add(cycle.Offset)
+ .Subtract(_ticker.RoundStartTimeSpan)
+ .Subtract(pausedTime)
+ .TotalSeconds % cycle.Duration.TotalSeconds);
+
+ var (direction, alpha) = GetShadow((uid, cycle), time);
+ shadow.Direction = direction;
+ shadow.Alpha = alpha;
+ }
+ }
+
+ [Pure]
+ public (Vector2 Direction, float Alpha) GetShadow(Entity<SunShadowCycleComponent> entity, float time)
+ {
+ // So essentially the values are stored as the percentages of the total duration just so it adjusts the speed
+ // dynamically and we don't have to manually handle it.
+ // It will lerp from each value to the next one with angle and length handled separately
+ var ratio = (float) (time / entity.Comp.Duration.TotalSeconds);
+
+ for (var i = entity.Comp.Directions.Count - 1; i >= 0; i--)
+ {
+ var dir = entity.Comp.Directions[i];
+
+ if (ratio > dir.Ratio)
+ {
+ var next = entity.Comp.Directions[(i + 1) % entity.Comp.Directions.Count];
+ float nextRatio;
+
+ // Last entry
+ if (i == entity.Comp.Directions.Count - 1)
+ {
+ nextRatio = next.Ratio + 1f;
+ }
+ else
+ {
+ nextRatio = next.Ratio;
+ }
+
+ var range = nextRatio - dir.Ratio;
+ var diff = (ratio - dir.Ratio) / range;
+ DebugTools.Assert(diff is >= 0f and <= 1f);
+
+ // We lerp angle + length separately as we don't want a straight-line lerp and want the rotation to be consistent.
+ var currentAngle = dir.Direction.ToAngle();
+ var nextAngle = next.Direction.ToAngle();
+
+ var angle = Angle.Lerp(currentAngle, nextAngle, diff);
+ // This is to avoid getting weird issues where the angle gets pretty close but length still noticeably catches up.
+ var lengthDiff = MathF.Pow(diff, 1f / 2f);
+ var length = float.Lerp(dir.Direction.Length(), next.Direction.Length(), lengthDiff);
+
+ var vector = angle.ToVec() * length;
+ var alpha = float.Lerp(dir.Alpha, next.Alpha, diff);
+ return (vector, alpha);
+ }
+ }
+
+ throw new InvalidOperationException();
+ }
+}
using Content.Client.GameTicking.Managers;
using Content.Shared;
using Content.Shared.Light.Components;
+using Content.Shared.Light.EntitySystems;
using Robust.Shared.Map.Components;
using Robust.Shared.Timing;
{
[Dependency] private readonly ClientGameTicker _ticker = default!;
[Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly MetaDataSystem _metadata = default!;
public override void Update(float frameTime)
{
base.Update(frameTime);
+
+ if (!_timing.IsFirstTimePredicted)
+ return;
+
var mapQuery = AllEntityQuery<LightCycleComponent, MapLightComponent>();
while (mapQuery.MoveNext(out var uid, out var cycle, out var map))
{
if (!cycle.Running)
continue;
+ // We still iterate paused entities as we still want to override the lighting color and not have
+ // it apply the server state
+ var pausedTime = _metadata.GetPauseTime(uid);
+
var time = (float) _timing.CurTime
.Add(cycle.Offset)
.Subtract(_ticker.RoundStartTimeSpan)
+ .Subtract(pausedTime)
.TotalSeconds;
var color = GetColor((uid, cycle), cycle.OriginalColor, time);
// Due to stencilling we essentially draw on unrooved tiles
while (tileEnumerator.MoveNext(out var tileRef))
{
- if (!_roof.IsRooved(roofEnt, tileRef.GridIndices))
+ var color = _roof.GetColor(roofEnt, tileRef.GridIndices);
+
+ if (color == null)
{
continue;
}
var local = _lookup.GetLocalBounds(tileRef, grid.Comp.TileSize);
- worldHandle.DrawRect(local, roof.Color);
+ worldHandle.DrawRect(local, color.Value);
}
}
}, null);
--- /dev/null
+using System.Numerics;
+using Content.Shared.Light.Components;
+using Robust.Client.Graphics;
+using Robust.Shared.Enums;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Physics;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Light;
+
+public sealed class SunShadowOverlay : Overlay
+{
+ public override OverlaySpace Space => OverlaySpace.BeforeLighting;
+
+ [Dependency] private readonly IClyde _clyde = default!;
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ [Dependency] private readonly IMapManager _mapManager = default!;
+ [Dependency] private readonly IPrototypeManager _protoManager = default!;
+ private readonly EntityLookupSystem _lookup;
+ private readonly SharedTransformSystem _xformSys;
+
+ private readonly HashSet<Entity<SunShadowCastComponent>> _shadows = new();
+
+ private IRenderTexture? _blurTarget;
+ private IRenderTexture? _target;
+
+ public SunShadowOverlay()
+ {
+ IoCManager.InjectDependencies(this);
+ _xformSys = _entManager.System<SharedTransformSystem>();
+ _lookup = _entManager.System<EntityLookupSystem>();
+ ZIndex = AfterLightTargetOverlay.ContentZIndex + 1;
+ }
+
+ private List<Entity<MapGridComponent>> _grids = new();
+
+ protected override void Draw(in OverlayDrawArgs args)
+ {
+ var viewport = args.Viewport;
+ var eye = viewport.Eye;
+
+ if (eye == null)
+ return;
+
+ _grids.Clear();
+ _mapManager.FindGridsIntersecting(args.MapId,
+ args.WorldBounds.Enlarged(SunShadowComponent.MaxLength),
+ ref _grids);
+
+ var worldHandle = args.WorldHandle;
+ var mapId = args.MapId;
+ var worldBounds = args.WorldBounds;
+ var targetSize = viewport.LightRenderTarget.Size;
+
+ if (_target?.Size != targetSize)
+ {
+ _target = _clyde
+ .CreateRenderTarget(targetSize,
+ new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb),
+ name: "sun-shadow-target");
+
+ if (_blurTarget?.Size != targetSize)
+ {
+ _blurTarget = _clyde
+ .CreateRenderTarget(targetSize, new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "sun-shadow-blur");
+ }
+ }
+
+ var lightScale = viewport.LightRenderTarget.Size / (Vector2)viewport.Size;
+ var scale = viewport.RenderScale / (Vector2.One / lightScale);
+
+ foreach (var grid in _grids)
+ {
+ if (!_entManager.TryGetComponent(grid.Owner, out SunShadowComponent? sun))
+ {
+ continue;
+ }
+
+ var direction = sun.Direction;
+ var alpha = Math.Clamp(sun.Alpha, 0f, 1f);
+
+ // Nowhere to cast to so ignore it.
+ if (direction.Equals(Vector2.Zero) || alpha == 0f)
+ continue;
+
+ // Feature todo: dynamic shadows for mobs and trees. Also ideally remove the fake tree shadows.
+ // TODO: Jittering still not quite perfect
+
+ var expandedBounds = worldBounds.Enlarged(direction.Length() + 0.01f);
+ _shadows.Clear();
+
+ // Draw shadow polys to stencil
+ args.WorldHandle.RenderInRenderTarget(_target,
+ () =>
+ {
+ var invMatrix =
+ _target.GetWorldToLocalMatrix(eye, scale);
+ var indices = new Vector2[PhysicsConstants.MaxPolygonVertices * 2];
+
+ // Go through shadows in range.
+
+ // For each one we:
+ // - Get the original vertices.
+ // - Extrapolate these along the sun direction.
+ // - Combine the above into 1 single polygon to draw.
+
+ // Note that this is range-limited for accuracy; if you set it too high it will clip through walls or other undesirable entities.
+ // This is probably not noticeable most of the time but if you want something "accurate" you'll want to code a solution.
+ // Ideally the CPU would have its own shadow-map copy that we could just ray-cast each vert into though
+ // You might need to batch verts or the likes as this could get expensive.
+ _lookup.GetEntitiesIntersecting(mapId, expandedBounds, _shadows);
+
+ foreach (var ent in _shadows)
+ {
+ var xform = _entManager.GetComponent<TransformComponent>(ent.Owner);
+ var worldMatrix = _xformSys.GetWorldMatrix(xform);
+ var renderMatrix = Matrix3x2.Multiply(worldMatrix, invMatrix);
+ var pointCount = ent.Comp.Points.Length;
+
+ Array.Copy(ent.Comp.Points, indices, pointCount);
+
+ for (var i = 0; i < pointCount; i++)
+ {
+ indices[pointCount + i] = indices[i] + direction;
+ }
+
+ var points = PhysicsHull.ComputePoints(indices, pointCount * 2);
+ worldHandle.SetTransform(renderMatrix);
+
+ worldHandle.DrawPrimitives(DrawPrimitiveTopology.TriangleFan, points, Color.White);
+ }
+ },
+ Color.Transparent);
+
+ // Slightly blur it just to avoid aliasing issues on the later viewport-wide blur.
+ _clyde.BlurRenderTarget(viewport, _target, _target, eye, 1f);
+
+ // Draw stencil (see roofoverlay).
+ args.WorldHandle.RenderInRenderTarget(viewport.LightRenderTarget,
+ () =>
+ {
+ var invMatrix =
+ viewport.LightRenderTarget.GetWorldToLocalMatrix(eye, scale);
+ worldHandle.SetTransform(invMatrix);
+
+ var maskShader = _protoManager.Index<ShaderPrototype>("Mix").Instance();
+ worldHandle.UseShader(maskShader);
+
+ worldHandle.DrawTextureRect(_target.Texture, worldBounds, Color.Black.WithAlpha(alpha));
+ }, null);
+ }
+ }
+}
using Content.Shared;
using Content.Shared.Light.Components;
+using Content.Shared.Light.EntitySystems;
using Robust.Shared.Random;
namespace Content.Server.Light.EntitySystems;
if (ent.Comp.InitialOffset)
{
- ent.Comp.Offset = _random.Next(ent.Comp.Duration);
- Dirty(ent);
+ SetOffset(ent, _random.Next(ent.Comp.Duration));
}
}
}
--- /dev/null
+using Content.Shared.Light.EntitySystems;
+
+namespace Content.Server.Light.EntitySystems;
+
+public sealed class SunShadowSystem : SharedSunShadowSystem
+{
+
+}
EnsureComp<LightCycleComponent>(mapUid);
+ EnsureComp<SunShadowComponent>(mapUid);
+ EnsureComp<SunShadowCycleComponent>(mapUid);
+
var moles = new float[Atmospherics.AdjustedNumberOfGases];
moles[(int) Gas.Oxygen] = 21.824779f;
moles[(int) Gas.Nitrogen] = 82.10312f;
{
[DataField, AutoNetworkedField]
public bool Enabled = true;
+
+ /// <summary>
+ /// Color for this roof. If null then falls back to the grid's color.
+ /// </summary>
+ /// <remarks>
+ /// If a tile is marked as rooved then the tile color will be used over any entity's colors on the tile.
+ /// </remarks>
+ [DataField, AutoNetworkedField]
+ public Color? Color;
}
--- /dev/null
+using System.Numerics;
+using Robust.Shared.GameStates;
+using Robust.Shared.Physics;
+
+namespace Content.Shared.Light.Components;
+
+/// <summary>
+/// Treats this entity as a 1x1 tile and extrapolates its position along the <see cref="SunShadowComponent"/> direction.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class SunShadowCastComponent : Component
+{
+ /// <summary>
+ /// Points that will be extruded to draw the shadow color.
+ /// Max <see cref="PhysicsConstants.MaxPolygonVertices"/>
+ /// </summary>
+ [DataField]
+ public Vector2[] Points = new[]
+ {
+ new Vector2(-0.5f, -0.5f),
+ new Vector2(0.5f, -0.5f),
+ new Vector2(0.5f, 0.5f),
+ new Vector2(-0.5f, 0.5f),
+ };
+}
--- /dev/null
+using System.Numerics;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Light.Components;
+
+/// <summary>
+/// When added to a map will apply shadows from <see cref="SunShadowComponent"/> to the lighting render target.
+/// </summary>
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class SunShadowComponent : Component
+{
+ /// <summary>
+ /// Maximum length of <see cref="Direction"/>. Mostly used in context of querying for grids off-screen.
+ /// </summary>
+ public const float MaxLength = 5f;
+
+ /// <summary>
+ /// Direction for the shadows to be extrapolated in.
+ /// </summary>
+ [DataField, AutoNetworkedField]
+ public Vector2 Direction;
+
+ [DataField, AutoNetworkedField]
+ public float Alpha;
+}
--- /dev/null
+using System.Linq;
+using System.Numerics;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Light.Components;
+
+/// <summary>
+/// Applies <see cref="SunShadowComponent"/> direction vectors based on a time-offset. Will track <see cref="LightCycleComponent"/> on on MapInit
+/// </summary>
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class SunShadowCycleComponent : Component
+{
+ /// <summary>
+ /// How long an entire cycle lasts
+ /// </summary>
+ [DataField, AutoNetworkedField]
+ public TimeSpan Duration = TimeSpan.FromMinutes(30);
+
+ [DataField, AutoNetworkedField]
+ public TimeSpan Offset;
+
+ // Originally had this as ratios but it was slightly annoying to use.
+
+ /// <summary>
+ /// Time to have each direction applied. Will lerp from the current value to the next one.
+ /// </summary>
+ [DataField, AutoNetworkedField]
+ public List<(float Ratio, Vector2 Direction, float Alpha)> Directions = new()
+ {
+ (0f, new Vector2(0f, 3f), 0f),
+ (0.25f, new Vector2(-3f, -0.1f), 0.5f),
+ (0.5f, new Vector2(0f, -3f), 0.8f),
+ (0.75f, new Vector2(3f, -0.1f), 0.5f),
+ };
+}
using Content.Shared.Light.Components;
using Robust.Shared.Map.Components;
-namespace Content.Shared;
+namespace Content.Shared.Light.EntitySystems;
public abstract class SharedLightCycleSystem : EntitySystem
{
}
}
+ public void SetOffset(Entity<LightCycleComponent> entity, TimeSpan offset)
+ {
+ entity.Comp.Offset = offset;
+ var ev = new LightCycleOffsetEvent(offset);
+
+ RaiseLocalEvent(entity, ref ev);
+ Dirty(entity);
+ }
+
public static Color GetColor(Entity<LightCycleComponent> cycle, Color color, float time)
{
if (cycle.Comp.Enabled)
return (crest - shift) * sen + shift;
}
}
+
+/// <summary>
+/// Raised when the offset on <see cref="LightCycleComponent"/> changes.
+/// </summary>
+[ByRefEvent]
+public record struct LightCycleOffsetEvent(TimeSpan Offset)
+{
+ public readonly TimeSpan Offset = Offset;
+}
+using System.Diagnostics.Contracts;
using Content.Shared.Light.Components;
using Content.Shared.Maps;
using Robust.Shared.Map;
/// Returns whether the specified tile is roof-occupied.
/// </summary>
/// <returns>Returns false if no data or not rooved.</returns>
+ [Pure]
public bool IsRooved(Entity<MapGridComponent, RoofComponent> grid, Vector2i index)
{
var roof = grid.Comp2;
return false;
}
+ [Pure]
+ public Color? GetColor(Entity<MapGridComponent, RoofComponent> grid, Vector2i index)
+ {
+ var roof = grid.Comp2;
+ var chunkOrigin = SharedMapSystem.GetChunkIndices(index, RoofComponent.ChunkSize);
+
+ if (roof.Data.TryGetValue(chunkOrigin, out var bitMask))
+ {
+ var chunkRelative = SharedMapSystem.GetChunkRelative(index, RoofComponent.ChunkSize);
+ var bitFlag = (ulong) 1 << (chunkRelative.X + chunkRelative.Y * RoofComponent.ChunkSize);
+
+ var isRoof = (bitMask & bitFlag) == bitFlag;
+
+ // Early out, otherwise check for components on tile.
+ if (isRoof)
+ {
+ return roof.Color;
+ }
+ }
+
+ _roofSet.Clear();
+ _lookup.GetLocalEntitiesIntersecting(grid.Owner, index, _roofSet);
+
+ foreach (var isRoofEnt in _roofSet)
+ {
+ if (!isRoofEnt.Comp.Enabled)
+ continue;
+
+ return isRoofEnt.Comp.Color ?? roof.Color;
+ }
+
+ return null;
+ }
+
public void SetRoof(Entity<MapGridComponent?, RoofComponent?> grid, Vector2i index, bool value)
{
if (!Resolve(grid, ref grid.Comp1, ref grid.Comp2, false))
--- /dev/null
+using Content.Shared.Light.Components;
+using Robust.Shared.Random;
+
+namespace Content.Shared.Light.EntitySystems;
+
+public abstract class SharedSunShadowSystem : EntitySystem
+{
+ [Dependency] private readonly IRobustRandom _random = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent<SunShadowCycleComponent, MapInitEvent>(OnCycleMapInit);
+ SubscribeLocalEvent<SunShadowCycleComponent, LightCycleOffsetEvent>(OnCycleOffset);
+ }
+
+ private void OnCycleOffset(Entity<SunShadowCycleComponent> ent, ref LightCycleOffsetEvent args)
+ {
+ // Okay so we synchronise with LightCycleComponent.
+ // However, the offset is only set on MapInit and we have no guarantee which one is ran first so we make sure.
+ ent.Comp.Offset = args.Offset;
+ Dirty(ent);
+ }
+
+ private void OnCycleMapInit(Entity<SunShadowCycleComponent> ent, ref MapInitEvent args)
+ {
+ if (TryComp(ent.Owner, out LightCycleComponent? lightCycle))
+ {
+ ent.Comp.Duration = lightCycle.Duration;
+ ent.Comp.Offset = lightCycle.Offset;
+ }
+ else
+ {
+ ent.Comp.Offset = _random.Next(ent.Comp.Duration);
+ }
+
+ Dirty(ent);
+ }
+}
- type: RadiationBlocker
resistance: 2
- type: BlockWeather
+ - type: SunShadowCast
- type: entity
parent: BaseWall