From 3572bf4739b9ff3c45037455d9522e9e5299157a Mon Sep 17 00:00:00 2001 From: William Lemon Date: Mon, 3 Aug 2026 12:45:11 +1000 Subject: [PATCH] Skia Shadow-Walk and DarkVision instead of VentCrawl (#6346) * What if Skia were awesome instead of sucking * Shhh Yaml Linter... Shhh * Small change to make it feel more consistant * Reworked shadowwalking to be lazy Much faster, preserves most of the feel * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update Content.Shared/_DV/Overlays/Components/DarkVisionComponent.cs Signed-off-by: pathetic meowmeow --------- Signed-off-by: pathetic meowmeow Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: pathetic meowmeow --- .../_DV/Overlays/DarkVisionOverlay.cs | 104 +++++++++++++ .../_DV/Overlays/DarkVisionSystem.cs | 56 +++++++ .../_DV/Light/SharedLightReactiveSystem.cs | 12 +- .../Components/DarkVisionComponent.cs | 30 ++++ .../_DV/ShadowWalk/ShadowWalkerComponent.cs | 36 +++++ .../_DV/ShadowWalk/SharedShadowWalkSystem.cs | 139 ++++++++++++++++++ .../_DV/Entities/Mobs/NPCs/skia.yml | 6 +- .../Prototypes/_DV/Shaders/darkvision.yml | 4 + .../Textures/_DV/Shaders/darkvision.swsl | 23 +++ 9 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 Content.Client/_DV/Overlays/DarkVisionOverlay.cs create mode 100644 Content.Client/_DV/Overlays/DarkVisionSystem.cs create mode 100644 Content.Shared/_DV/Overlays/Components/DarkVisionComponent.cs create mode 100644 Content.Shared/_DV/ShadowWalk/ShadowWalkerComponent.cs create mode 100644 Content.Shared/_DV/ShadowWalk/SharedShadowWalkSystem.cs create mode 100644 Resources/Prototypes/_DV/Shaders/darkvision.yml create mode 100644 Resources/Textures/_DV/Shaders/darkvision.swsl diff --git a/Content.Client/_DV/Overlays/DarkVisionOverlay.cs b/Content.Client/_DV/Overlays/DarkVisionOverlay.cs new file mode 100644 index 00000000000..1a29215ffbf --- /dev/null +++ b/Content.Client/_DV/Overlays/DarkVisionOverlay.cs @@ -0,0 +1,104 @@ +using System.Numerics; +using Content.Client.Graphics; +using Robust.Client.Graphics; +using Robust.Shared.Enums; +using Robust.Shared.Prototypes; + +namespace Content.Client._DV.Overlays; + +/// +/// Makes darkness visible, and bright lights painfully visible +/// Tweakable. Algo is max((light*gain)^exp, lightFloor) +/// +public sealed class DarkVisionOverlay : Overlay +{ + [Dependency] private readonly IClyde _clyde = default!; + [Dependency] private readonly IPrototypeManager _prototype = default!; + + public override OverlaySpace Space => OverlaySpace.WorldSpaceBelowWorld; + + private readonly ProtoId _shaderProto = "DarkVision"; + + public float LightFloor = 0.5f; + public float LightGain = 2f; + public float LightExp = 1f; + + private readonly ShaderInstance _copyShader; + private readonly ShaderInstance _remapShader; + private readonly OverlayResourceCache _resources = new(); + + public DarkVisionOverlay() + { + IoCManager.InjectDependencies(this); + + var proto = _prototype.Index(_shaderProto); + _remapShader = proto.InstanceUnique(); + // With floor 0, gain 1, exp 1 the shader is an exact blend-mode-none copy. + _copyShader = proto.InstanceUnique(); + _copyShader.SetParameter("lightFloor", 0f); + _copyShader.SetParameter("lightGain", 1f); + _copyShader.SetParameter("lightExp", 1f); + } + + protected override void Draw(in OverlayDrawArgs args) + { + var viewport = args.Viewport; + var worldHandle = args.WorldHandle; + + if (viewport.Eye == null) + return; + + var lightTarget = viewport.LightRenderTarget; + var res = _resources.GetForViewport(viewport, static _ => new CachedResources()); + + if (res.ScratchTarget?.Size != lightTarget.Size) + { + res.ScratchTarget?.Dispose(); + res.ScratchTarget = _clyde.CreateLightRenderTarget(lightTarget.Size, "darkvision-scratch", depthStencil: false); + } + + var bounds = args.WorldBounds; + var lightScale = lightTarget.Size / (Vector2) viewport.Size; + var scale = viewport.RenderScale / (Vector2.One / lightScale); + var localMatrix = lightTarget.GetWorldToLocalMatrix(viewport.Eye, scale); + + // Copy the light buffer aside first: a texture can't be sampled while it is also the + // render target being drawn into. + worldHandle.RenderInRenderTarget(res.ScratchTarget, () => + { + worldHandle.UseShader(_copyShader); + worldHandle.SetTransform(localMatrix); + worldHandle.DrawTextureRect(lightTarget.Texture, bounds); + worldHandle.UseShader(null); + }, Color.Black); + + // Then write it back through the remap. + _remapShader.SetParameter("lightFloor", LightFloor); + _remapShader.SetParameter("lightGain", LightGain); + _remapShader.SetParameter("lightExp", LightExp); + worldHandle.RenderInRenderTarget(lightTarget, () => + { + worldHandle.UseShader(_remapShader); + worldHandle.SetTransform(localMatrix); + worldHandle.DrawTextureRect(res.ScratchTarget.Texture, bounds); + worldHandle.UseShader(null); + }, null); + } + + protected override void DisposeBehavior() + { + _resources.Dispose(); + + base.DisposeBehavior(); + } + + private sealed class CachedResources : IDisposable + { + public IRenderTexture? ScratchTarget; + + public void Dispose() + { + ScratchTarget?.Dispose(); + } + } +} diff --git a/Content.Client/_DV/Overlays/DarkVisionSystem.cs b/Content.Client/_DV/Overlays/DarkVisionSystem.cs new file mode 100644 index 00000000000..835156e8595 --- /dev/null +++ b/Content.Client/_DV/Overlays/DarkVisionSystem.cs @@ -0,0 +1,56 @@ +using Content.Shared._DV.Overlays.Components; +using Robust.Client.Graphics; +using Robust.Shared.Player; + +namespace Content.Client._DV.Overlays; + +public sealed class DarkVisionSystem : EntitySystem +{ + [Dependency] private readonly IOverlayManager _overlayMan = default!; + [Dependency] private readonly ISharedPlayerManager _playerMan = default!; + + private DarkVisionOverlay _overlay = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnInit); + SubscribeLocalEvent(OnShutdown); + SubscribeLocalEvent(OnPlayerAttached); + SubscribeLocalEvent(OnPlayerDetached); + + _overlay = new(); + } + + private void OnInit(Entity ent, ref ComponentInit args) + { + if (ent.Owner == _playerMan.LocalEntity) + EnableOverlay(ent.Comp); + } + + private void OnShutdown(Entity ent, ref ComponentShutdown args) + { + if (ent.Owner == _playerMan.LocalEntity) + _overlayMan.RemoveOverlay(_overlay); + } + + private void OnPlayerAttached(Entity ent, ref LocalPlayerAttachedEvent args) + { + EnableOverlay(ent.Comp); + } + + private void OnPlayerDetached(Entity ent, ref LocalPlayerDetachedEvent args) + { + _overlayMan.RemoveOverlay(_overlay); + } + + private void EnableOverlay(DarkVisionComponent comp) + { + _overlay.LightFloor = comp.LightFloor; + _overlay.LightGain = comp.LightGain; + _overlay.LightExp = comp.LightExp; + if (!_overlayMan.HasOverlay()) + _overlayMan.AddOverlay(_overlay); + } +} diff --git a/Content.Shared/_DV/Light/SharedLightReactiveSystem.cs b/Content.Shared/_DV/Light/SharedLightReactiveSystem.cs index 389a21fe4fa..2cddb4b20e3 100644 --- a/Content.Shared/_DV/Light/SharedLightReactiveSystem.cs +++ b/Content.Shared/_DV/Light/SharedLightReactiveSystem.cs @@ -70,13 +70,23 @@ public abstract class SharedLightReactiveSystem : EntitySystem /// Avoid calling this too often, as it can be expensive. /// public float GetLightLevelForPoint(EntityUid uid, TransformComponent? xform = null) + { + return GetLightLevelAtPosition(uid, _transform.GetWorldPosition(uid), xform); + } + + /// + /// Gets the light level at an arbitrary world position, using for the + /// light lookup and map resolution. Lets callers sample somewhere other than the entity's + /// centre — e.g. a point just outside a wall, so the wall's own body can occlude the ray. + /// Avoid calling this too often, as it can be expensive. + /// + public float GetLightLevelAtPosition(EntityUid uid, Vector2 pos, TransformComponent? xform = null) { float val = 0.0f; // Get the current map entity so we can get a MapLightComponent from it if it has one var map = _transform.GetMap((uid, xform)); if (TryComp(map, out MapLightComponent? mapLight)) val += (mapLight.AmbientLightColor.R + mapLight.AmbientLightColor.G + mapLight.AmbientLightColor.B) / 3f; - var pos = _transform.GetWorldPosition(uid); foreach (var (lightUid, lightComp) in GetLights(uid)) { diff --git a/Content.Shared/_DV/Overlays/Components/DarkVisionComponent.cs b/Content.Shared/_DV/Overlays/Components/DarkVisionComponent.cs new file mode 100644 index 00000000000..3ddc961d1ff --- /dev/null +++ b/Content.Shared/_DV/Overlays/Components/DarkVisionComponent.cs @@ -0,0 +1,30 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared._DV.Overlays.Components; + +/// +/// Gives the owner darkvision: lighting still renders, but total darkness is raised to +/// brightness instead of pitch black. Unlike night vision this keeps +/// the whole lighting gradient visible, so creatures like the Skia can judge what is and isn't dark enough for them. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class DarkVisionComponent : Component +{ + /// + /// Brightness that full darkness renders at, 0-1. Rendered light is clamped to a minimum of this value. + /// + [DataField, AutoNetworkedField] + public float LightFloor = 0.2f; + + /// + /// Multiplier applied to actual light on top of the floor. Values above 1 overbrighten lit areas so they are unmistakable next to the grey darkness floor. + /// + [DataField, AutoNetworkedField] + public float LightGain = 8f; + + /// + /// Exponent applied to lights, to make brighter areas look notably brighter + /// + [DataField, AutoNetworkedField] + public float LightExp = 2f; +} diff --git a/Content.Shared/_DV/ShadowWalk/ShadowWalkerComponent.cs b/Content.Shared/_DV/ShadowWalk/ShadowWalkerComponent.cs new file mode 100644 index 00000000000..689ad0aa555 --- /dev/null +++ b/Content.Shared/_DV/ShadowWalk/ShadowWalkerComponent.cs @@ -0,0 +1,36 @@ +using Robust.Shared.GameStates; +using Robust.Shared.Timing; + +namespace Content.Shared._DV.ShadowWalk; + +/// +/// Lets this entity walk straight through solid static objects (walls, doors, windows...) +/// while the entity itself is bathed in darkness (the same light level it heals in.) +/// Mobs and projectiles always stay solid. +/// +/// On collision, checks our light level. Objects we're stuck in are tagged in +/// +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class ShadowWalkerComponent : Component +{ + /// + /// Light level below which an object counts as bathed in darkness. + /// If the entity has a LightLevelHealthComponent its DarkThreshold is used + /// instead, so objects are passable exactly where the entity would heal. + /// + [DataField] + public float DarkThreshold = 0.3f; + + /// + /// Objects we're currently in. Objects in this list are never solid until we fully leave. + /// + public HashSet PassableEntities = new(); + + /// + /// Light level for this tick, to avoid re-calculating for more than one collision a tick. + /// + public GameTick LastLightCheckTick = GameTick.Zero; + + public float LastLightLevel; +} diff --git a/Content.Shared/_DV/ShadowWalk/SharedShadowWalkSystem.cs b/Content.Shared/_DV/ShadowWalk/SharedShadowWalkSystem.cs new file mode 100644 index 00000000000..5119175396d --- /dev/null +++ b/Content.Shared/_DV/ShadowWalk/SharedShadowWalkSystem.cs @@ -0,0 +1,139 @@ +using Content.Shared._DV.Body; +using Content.Shared._DV.Light; +using Content.Shared.Projectiles; +using Robust.Shared.Map.Components; +using Robust.Shared.Physics; +using Robust.Shared.Physics.Components; +using Robust.Shared.Physics.Events; +using Robust.Shared.Timing; + +namespace Content.Shared._DV.ShadowWalk; + +/// +/// Can walk through darkness freely. +/// +public sealed partial class SharedShadowWalkSystem : EntitySystem +{ + [Dependency] private readonly EntityLookupSystem _lookup = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly SharedLightReactiveSystem _lightReactive = default!; + [Dependency] private readonly SharedTransformSystem _transform = default!; + + /// + /// How far outside a tagged object's AABB the walker's centre must be before the object is untagged (and so becomes solid again on the next collision). + /// At least the walker's collision radius, so an object is never made solid while it still overlaps the walker. + /// + private const float UnstickMargin = 0.45f; + + /// + /// Gamefeel. Non-walls get a bigger unstick margin so they stay unstick even if you clip into a wall. Prevents getting stuck in walls. + /// + private const float MovableUnstickMargin = 1f; + + private EntityQuery _lightHealthQuery; + private EntityQuery _physicsQuery; + private EntityQuery _projectileQuery; + + private readonly List _toRemove = []; + + public override void Initialize() + { + base.Initialize(); + + _lightHealthQuery = GetEntityQuery(); + _physicsQuery = GetEntityQuery(); + _projectileQuery = GetEntityQuery(); + + SubscribeLocalEvent(OnPreventCollide); + } + + public override void Update(float frameTime) + { + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var comp)) + { + if (comp.PassableEntities.Count == 0) + continue; + + var worldPos = _transform.GetWorldPosition(uid); + + _toRemove.Clear(); + foreach (var other in comp.PassableEntities) + { + // Untag anything we've deleted or fully walked clear of; the next collision with it will re-check the light level from scratch. + if (Deleted(other)) + { + _toRemove.Add(other); + continue; + } + + var margin = UnstickMargin; + // Non-statics get a bigger margin :) + if (_physicsQuery.TryComp(other, out var body) && body.BodyType != BodyType.Static) + margin += MovableUnstickMargin; + + if (!_lookup.GetWorldAABB(other).Enlarged(margin).Contains(worldPos)) + _toRemove.Add(other); + } + + foreach (var other in _toRemove) + comp.PassableEntities.Remove(other); + } + } + + private void OnPreventCollide(Entity ent, ref PreventCollideEvent args) + { + if (args.Cancelled) + return; + + // Only phase through hard blockers; sensor fixtures must keep triggering. + if (!args.OurFixture.Hard || !args.OtherFixture.Hard) + return; + + // Already phasing through this one: keep it passable until we've left it (pruned in + // Update), so a light change mid-overlap can never trap us inside it. + if (ent.Comp.PassableEntities.Contains(args.OtherEntity)) + { + args.Cancelled = true; + return; + } + + if (!CanPhaseThrough(args.OtherEntity, args.OtherBody)) + return; + + // A fresh collision: only phase if the walker itself is currently in darkness. + if (!InDarkness(ent)) + return; + + args.Cancelled = true; + ent.Comp.PassableEntities.Add(args.OtherEntity); + } + + private bool CanPhaseThrough(EntityUid other, PhysicsComponent otherBody) + { + if (otherBody.BodyType == BodyType.KinematicController) + return false; + // Bullets never pass or hit based on collision timing. + if (_projectileQuery.HasComp(other)) + return false; + + return true; + } + + private bool InDarkness(Entity ent) + { + // Darkness is whatever the walker heals in, if it heals in darkness at all. + var threshold = _lightHealthQuery.TryComp(ent, out var lightHealth) + ? lightHealth.DarkThreshold + : ent.Comp.DarkThreshold; + + var curTick = _timing.CurTick; + if (ent.Comp.LastLightCheckTick != curTick) + { + ent.Comp.LastLightLevel = _lightReactive.GetLightLevelForPoint(ent.Owner); + ent.Comp.LastLightCheckTick = curTick; + } + + return ent.Comp.LastLightLevel < threshold; + } +} diff --git a/Resources/Prototypes/_DV/Entities/Mobs/NPCs/skia.yml b/Resources/Prototypes/_DV/Entities/Mobs/NPCs/skia.yml index ff3c408acbb..1b7f77591e3 100644 --- a/Resources/Prototypes/_DV/Entities/Mobs/NPCs/skia.yml +++ b/Resources/Prototypes/_DV/Entities/Mobs/NPCs/skia.yml @@ -1,5 +1,5 @@ - type: entity - parent: [ SimpleSpaceMobBase, MobCombat, DVNodeCrawler ] + parent: [ SimpleSpaceMobBase, MobCombat ] id: MobSkia name: skia description: A shadow given form, lashing out at anything that comes too close. @@ -46,6 +46,7 @@ - type: MovementSpeedModifier baseWalkSpeed: 2.25 baseSprintSpeed: 3.75 + - type: ShadowWalker # Phases through walls and other static objects that are bathed in darkness. - type: NoSlip - type: MovedByPressure enabled: false @@ -128,8 +129,7 @@ speedModifier: 2.5 # needs to be fast because they'll get ganked otherwise useSound: path: /Audio/Items/crowbar.ogg - - type: NightVision - drawOverlay: false + - type: DarkVision # Not NightVision: keeps the lighting gradient visible so they can judge what's dark enough to heal/shadow-walk in. - type: EmbedImmune immuneTo: components: diff --git a/Resources/Prototypes/_DV/Shaders/darkvision.yml b/Resources/Prototypes/_DV/Shaders/darkvision.yml new file mode 100644 index 00000000000..5dd6037fcc8 --- /dev/null +++ b/Resources/Prototypes/_DV/Shaders/darkvision.yml @@ -0,0 +1,4 @@ +- type: shader + id: DarkVision + kind: source + path: "/Textures/_DV/Shaders/darkvision.swsl" diff --git a/Resources/Textures/_DV/Shaders/darkvision.swsl b/Resources/Textures/_DV/Shaders/darkvision.swsl new file mode 100644 index 00000000000..f45940e2f8a --- /dev/null +++ b/Resources/Textures/_DV/Shaders/darkvision.swsl @@ -0,0 +1,23 @@ +// Remaps the lighting buffer for darkvision: output = lightFloor + light * lightGain. +// blend_mode none because this rewrites the buffer wholesale; with lightFloor 0 and +// lightGain 1 it acts as an exact copy. +light_mode unshaded; +blend_mode none; + +uniform highp float lightFloor; +uniform highp float lightGain; +uniform highp float lightExp; + +highp vec3 lowerBound(highp vec3 col, highp float bound) { + return vec3( + max(col.r, bound), + max(col.g, bound), + max(col.b, bound) + ); +} + +void fragment() { + highp vec4 light = zTexture(UV); + + COLOR = vec4(lowerBound(pow((light.rgb * lightGain), vec3(lightExp)), lightFloor), 1.0); +}