diff --git a/Content.Client/Light/AfterLightTargetOverlay.cs b/Content.Client/Light/AfterLightTargetOverlay.cs index 06c508a54e..7856fd4ded 100644 --- a/Content.Client/Light/AfterLightTargetOverlay.cs +++ b/Content.Client/Light/AfterLightTargetOverlay.cs @@ -54,6 +54,6 @@ public sealed class AfterLightTargetOverlay : Overlay worldHandle.SetTransform(localMatrix); worldHandle.DrawTextureRectRegion(lightOverlay.EnlargedLightTarget.Texture, bounds, subRegion: subRegion); - }, null); + }, Color.Transparent); } } diff --git a/Content.Client/Light/EntitySystems/PlanetLightSystem.cs b/Content.Client/Light/EntitySystems/PlanetLightSystem.cs index 2da67137ed..cbe2f47f78 100644 --- a/Content.Client/Light/EntitySystems/PlanetLightSystem.cs +++ b/Content.Client/Light/EntitySystems/PlanetLightSystem.cs @@ -16,6 +16,7 @@ public sealed class PlanetLightSystem : EntitySystem _overlayMan.AddOverlay(new RoofOverlay(EntityManager)); _overlayMan.AddOverlay(new TileEmissionOverlay(EntityManager)); _overlayMan.AddOverlay(new LightBlurOverlay()); + _overlayMan.AddOverlay(new SunShadowOverlay()); _overlayMan.AddOverlay(new AfterLightTargetOverlay()); } @@ -31,6 +32,7 @@ public sealed class PlanetLightSystem : EntitySystem _overlayMan.RemoveOverlay(); _overlayMan.RemoveOverlay(); _overlayMan.RemoveOverlay(); + _overlayMan.RemoveOverlay(); _overlayMan.RemoveOverlay(); } } diff --git a/Content.Client/Light/EntitySystems/SunShadowSystem.cs b/Content.Client/Light/EntitySystems/SunShadowSystem.cs new file mode 100644 index 0000000000..6f7a965a61 --- /dev/null +++ b/Content.Client/Light/EntitySystems/SunShadowSystem.cs @@ -0,0 +1,92 @@ +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(); + 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 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(); + } +} diff --git a/Content.Client/Light/LightCycleSystem.cs b/Content.Client/Light/LightCycleSystem.cs index 9e19423cc3..8de0165fd2 100644 --- a/Content.Client/Light/LightCycleSystem.cs +++ b/Content.Client/Light/LightCycleSystem.cs @@ -1,6 +1,7 @@ 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; @@ -11,19 +12,29 @@ public sealed class LightCycleSystem : SharedLightCycleSystem { [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(); 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); diff --git a/Content.Client/Light/RoofOverlay.cs b/Content.Client/Light/RoofOverlay.cs index 0648f8624f..8944630169 100644 --- a/Content.Client/Light/RoofOverlay.cs +++ b/Content.Client/Light/RoofOverlay.cs @@ -94,13 +94,15 @@ public sealed class RoofOverlay : Overlay // 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); diff --git a/Content.Client/Light/SunShadowOverlay.cs b/Content.Client/Light/SunShadowOverlay.cs new file mode 100644 index 0000000000..de8b5ed490 --- /dev/null +++ b/Content.Client/Light/SunShadowOverlay.cs @@ -0,0 +1,154 @@ +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> _shadows = new(); + + private IRenderTexture? _blurTarget; + private IRenderTexture? _target; + + public SunShadowOverlay() + { + IoCManager.InjectDependencies(this); + _xformSys = _entManager.System(); + _lookup = _entManager.System(); + ZIndex = AfterLightTargetOverlay.ContentZIndex + 1; + } + + private List> _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(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("Mix").Instance(); + worldHandle.UseShader(maskShader); + + worldHandle.DrawTextureRect(_target.Texture, worldBounds, Color.Black.WithAlpha(alpha)); + }, null); + } + } +} diff --git a/Content.Server/Light/EntitySystems/LightCycleSystem.cs b/Content.Server/Light/EntitySystems/LightCycleSystem.cs index 7d2eacc8bb..89863ed3dc 100644 --- a/Content.Server/Light/EntitySystems/LightCycleSystem.cs +++ b/Content.Server/Light/EntitySystems/LightCycleSystem.cs @@ -1,5 +1,6 @@ using Content.Shared; using Content.Shared.Light.Components; +using Content.Shared.Light.EntitySystems; using Robust.Shared.Random; namespace Content.Server.Light.EntitySystems; @@ -15,8 +16,7 @@ public sealed class LightCycleSystem : SharedLightCycleSystem if (ent.Comp.InitialOffset) { - ent.Comp.Offset = _random.Next(ent.Comp.Duration); - Dirty(ent); + SetOffset(ent, _random.Next(ent.Comp.Duration)); } } } diff --git a/Content.Server/Light/EntitySystems/SunShadowSystem.cs b/Content.Server/Light/EntitySystems/SunShadowSystem.cs new file mode 100644 index 0000000000..87ccfebc52 --- /dev/null +++ b/Content.Server/Light/EntitySystems/SunShadowSystem.cs @@ -0,0 +1,8 @@ +using Content.Shared.Light.EntitySystems; + +namespace Content.Server.Light.EntitySystems; + +public sealed class SunShadowSystem : SharedSunShadowSystem +{ + +} diff --git a/Content.Server/Parallax/BiomeSystem.cs b/Content.Server/Parallax/BiomeSystem.cs index e419e90fd3..89aa754850 100644 --- a/Content.Server/Parallax/BiomeSystem.cs +++ b/Content.Server/Parallax/BiomeSystem.cs @@ -1033,6 +1033,9 @@ public sealed partial class BiomeSystem : SharedBiomeSystem EnsureComp(mapUid); + EnsureComp(mapUid); + EnsureComp(mapUid); + var moles = new float[Atmospherics.AdjustedNumberOfGases]; moles[(int) Gas.Oxygen] = 21.824779f; moles[(int) Gas.Nitrogen] = 82.10312f; diff --git a/Content.Shared/Light/Components/IsRoofComponent.cs b/Content.Shared/Light/Components/IsRoofComponent.cs index d64793f358..624ad63e55 100644 --- a/Content.Shared/Light/Components/IsRoofComponent.cs +++ b/Content.Shared/Light/Components/IsRoofComponent.cs @@ -10,4 +10,13 @@ public sealed partial class IsRoofComponent : Component { [DataField, AutoNetworkedField] public bool Enabled = true; + + /// + /// Color for this roof. If null then falls back to the grid's color. + /// + /// + /// If a tile is marked as rooved then the tile color will be used over any entity's colors on the tile. + /// + [DataField, AutoNetworkedField] + public Color? Color; } diff --git a/Content.Shared/Light/Components/SunShadowCastComponent.cs b/Content.Shared/Light/Components/SunShadowCastComponent.cs new file mode 100644 index 0000000000..d3fc6dc75e --- /dev/null +++ b/Content.Shared/Light/Components/SunShadowCastComponent.cs @@ -0,0 +1,25 @@ +using System.Numerics; +using Robust.Shared.GameStates; +using Robust.Shared.Physics; + +namespace Content.Shared.Light.Components; + +/// +/// Treats this entity as a 1x1 tile and extrapolates its position along the direction. +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class SunShadowCastComponent : Component +{ + /// + /// Points that will be extruded to draw the shadow color. + /// Max + /// + [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), + }; +} diff --git a/Content.Shared/Light/Components/SunShadowComponent.cs b/Content.Shared/Light/Components/SunShadowComponent.cs new file mode 100644 index 0000000000..f7f42717a5 --- /dev/null +++ b/Content.Shared/Light/Components/SunShadowComponent.cs @@ -0,0 +1,25 @@ +using System.Numerics; +using Robust.Shared.GameStates; + +namespace Content.Shared.Light.Components; + +/// +/// When added to a map will apply shadows from to the lighting render target. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class SunShadowComponent : Component +{ + /// + /// Maximum length of . Mostly used in context of querying for grids off-screen. + /// + public const float MaxLength = 5f; + + /// + /// Direction for the shadows to be extrapolated in. + /// + [DataField, AutoNetworkedField] + public Vector2 Direction; + + [DataField, AutoNetworkedField] + public float Alpha; +} diff --git a/Content.Shared/Light/Components/SunShadowCycleComponent.cs b/Content.Shared/Light/Components/SunShadowCycleComponent.cs new file mode 100644 index 0000000000..0948091ecf --- /dev/null +++ b/Content.Shared/Light/Components/SunShadowCycleComponent.cs @@ -0,0 +1,35 @@ +using System.Linq; +using System.Numerics; +using Robust.Shared.GameStates; + +namespace Content.Shared.Light.Components; + +/// +/// Applies direction vectors based on a time-offset. Will track on on MapInit +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class SunShadowCycleComponent : Component +{ + /// + /// How long an entire cycle lasts + /// + [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. + + /// + /// Time to have each direction applied. Will lerp from the current value to the next one. + /// + [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), + }; +} diff --git a/Content.Shared/SharedLightCycleSystem.cs b/Content.Shared/Light/EntitySystems/SharedLightCycleSystem.cs similarity index 90% rename from Content.Shared/SharedLightCycleSystem.cs rename to Content.Shared/Light/EntitySystems/SharedLightCycleSystem.cs index 1ba947f78a..3b3d6d702c 100644 --- a/Content.Shared/SharedLightCycleSystem.cs +++ b/Content.Shared/Light/EntitySystems/SharedLightCycleSystem.cs @@ -1,7 +1,7 @@ using Content.Shared.Light.Components; using Robust.Shared.Map.Components; -namespace Content.Shared; +namespace Content.Shared.Light.EntitySystems; public abstract class SharedLightCycleSystem : EntitySystem { @@ -30,6 +30,15 @@ public abstract class SharedLightCycleSystem : EntitySystem } } + public void SetOffset(Entity entity, TimeSpan offset) + { + entity.Comp.Offset = offset; + var ev = new LightCycleOffsetEvent(offset); + + RaiseLocalEvent(entity, ref ev); + Dirty(entity); + } + public static Color GetColor(Entity cycle, Color color, float time) { if (cycle.Comp.Enabled) @@ -114,3 +123,12 @@ public abstract class SharedLightCycleSystem : EntitySystem return (crest - shift) * sen + shift; } } + +/// +/// Raised when the offset on changes. +/// +[ByRefEvent] +public record struct LightCycleOffsetEvent(TimeSpan Offset) +{ + public readonly TimeSpan Offset = Offset; +} diff --git a/Content.Shared/Light/EntitySystems/SharedRoofSystem.cs b/Content.Shared/Light/EntitySystems/SharedRoofSystem.cs index 2d19b8ba87..46ec418579 100644 --- a/Content.Shared/Light/EntitySystems/SharedRoofSystem.cs +++ b/Content.Shared/Light/EntitySystems/SharedRoofSystem.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.Contracts; using Content.Shared.Light.Components; using Content.Shared.Maps; using Robust.Shared.Map; @@ -18,6 +19,7 @@ public abstract class SharedRoofSystem : EntitySystem /// Returns whether the specified tile is roof-occupied. /// /// Returns false if no data or not rooved. + [Pure] public bool IsRooved(Entity grid, Vector2i index) { var roof = grid.Comp2; @@ -49,6 +51,40 @@ public abstract class SharedRoofSystem : EntitySystem return false; } + [Pure] + public Color? GetColor(Entity 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 grid, Vector2i index, bool value) { if (!Resolve(grid, ref grid.Comp1, ref grid.Comp2, false)) diff --git a/Content.Shared/Light/EntitySystems/SharedSunShadowSystem.cs b/Content.Shared/Light/EntitySystems/SharedSunShadowSystem.cs new file mode 100644 index 0000000000..ed6069d0f9 --- /dev/null +++ b/Content.Shared/Light/EntitySystems/SharedSunShadowSystem.cs @@ -0,0 +1,39 @@ +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(OnCycleMapInit); + SubscribeLocalEvent(OnCycleOffset); + } + + private void OnCycleOffset(Entity 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 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); + } +} diff --git a/Resources/Prototypes/Entities/Structures/Walls/walls.yml b/Resources/Prototypes/Entities/Structures/Walls/walls.yml index dd6b3d36bc..57b8aa915e 100644 --- a/Resources/Prototypes/Entities/Structures/Walls/walls.yml +++ b/Resources/Prototypes/Entities/Structures/Walls/walls.yml @@ -51,6 +51,7 @@ - type: RadiationBlocker resistance: 2 - type: BlockWeather + - type: SunShadowCast - type: entity parent: BaseWall