From cfd925d9e751e27718157040d432e907e71d59ba Mon Sep 17 00:00:00 2001 From: InsoPL Date: Sun, 4 Oct 2026 12:58:17 +0000 Subject: [PATCH] Vacuum shader (#46038) * init * slider * fixes * fixes * vacuum * toggle * turned on be default lol * typo * mediump * marker thingis * GetWorldPositionRotationMatrixWithInv * cvar dynamic * fixes * magic numbers bad * discard * refactors will continue until clean code improves * format * comm --------- Co-authored-by: Pok245 <113675512+Pok27@users.noreply.github.com> --- .../GasTileVacuumOverlaySystem.cs | 65 ++ .../Atmos/Overlays/GasTileVacuumOverlay.cs | 196 +++++ .../Options/UI/Tabs/AccessibilityTab.xaml | 1 + .../Options/UI/Tabs/AccessibilityTab.xaml.cs | 8 + .../EntitySystems/GasTileOverlaySystem.cs | 818 +++++++++--------- .../SharedGasTileOverlaySystem.cs | 5 + .../CCVar/CCVars.Accessibility.Motion.cs | 12 + .../en-US/escape-menu/ui/options-menu.ftl | 1 + Resources/Prototypes/Shaders/shaders.yml | 5 + .../Textures/Shaders/vacuum_desaturation.swsl | 14 + 10 files changed, 715 insertions(+), 410 deletions(-) create mode 100644 Content.Client/Atmos/EntitySystems/GasTileVacuumOverlaySystem.cs create mode 100644 Content.Client/Atmos/Overlays/GasTileVacuumOverlay.cs create mode 100644 Resources/Textures/Shaders/vacuum_desaturation.swsl diff --git a/Content.Client/Atmos/EntitySystems/GasTileVacuumOverlaySystem.cs b/Content.Client/Atmos/EntitySystems/GasTileVacuumOverlaySystem.cs new file mode 100644 index 0000000000..19dcd5bfec --- /dev/null +++ b/Content.Client/Atmos/EntitySystems/GasTileVacuumOverlaySystem.cs @@ -0,0 +1,65 @@ +using Content.Client.Atmos.Overlays; +using Content.Shared.CCVar; +using JetBrains.Annotations; +using Robust.Client.Graphics; +using Robust.Shared.Configuration; + +namespace Content.Client.Atmos.EntitySystems; + +/// +/// System responsible for rendering vacuum effects using . +/// +[UsedImplicitly] +public sealed partial class GasTileVacuumOverlaySystem : EntitySystem +{ + [Dependency] private IOverlayManager _overlayMan = default!; + [Dependency] private IConfigurationManager _cfgManager = default!; + + private GasTileVacuumOverlay? _gasTileVacuumOverlay; + private bool _vacuumOverlayEnabledDueToCvar; // due to CVar being enabled + private bool _vacuumOverlayEnabledIntensity; // due to non zero intensity in setting + + public override void Initialize() + { + base.Initialize(); + + _cfgManager.OnValueChanged(CCVars.VacuumOverlay, OnVacuumOverlayChanged, true); + _cfgManager.OnValueChanged(CCVars.VacuumOverlayIntensity, OnVacuumOverlayIntensityChanged, true); + } + + public override void Shutdown() + { + base.Shutdown(); + + _cfgManager.UnsubValueChanged(CCVars.VacuumOverlay, OnVacuumOverlayChanged); + _cfgManager.UnsubValueChanged(CCVars.VacuumOverlayIntensity, OnVacuumOverlayIntensityChanged); + _overlayMan.RemoveOverlay(); + } + + private void OnVacuumOverlayChanged(bool enabled) + { + _vacuumOverlayEnabledDueToCvar = enabled; + UpdateOverlay(); + } + + private void OnVacuumOverlayIntensityChanged(float intensity) + { + _vacuumOverlayEnabledIntensity = intensity > 0f; + UpdateOverlay(); + } + + private void UpdateOverlay() + { + if (_vacuumOverlayEnabledDueToCvar && _vacuumOverlayEnabledIntensity) + { + _gasTileVacuumOverlay ??= new GasTileVacuumOverlay(); + + if (!_overlayMan.HasOverlay()) + _overlayMan.AddOverlay(_gasTileVacuumOverlay); + } + else + { + _overlayMan.RemoveOverlay(); + } + } +} diff --git a/Content.Client/Atmos/Overlays/GasTileVacuumOverlay.cs b/Content.Client/Atmos/Overlays/GasTileVacuumOverlay.cs new file mode 100644 index 0000000000..0ddcd64a27 --- /dev/null +++ b/Content.Client/Atmos/Overlays/GasTileVacuumOverlay.cs @@ -0,0 +1,196 @@ +using System.Numerics; +using Content.Client.Graphics; +using Content.Shared.Atmos; +using Content.Shared.Atmos.Components; +using Content.Shared.Atmos.EntitySystems; +using Content.Shared.CCVar; +using Robust.Client.Graphics; +using Robust.Shared.Configuration; +using Robust.Shared.Enums; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Prototypes; +using Color = Robust.Shared.Maths.Color; + +namespace Content.Client.Atmos.Overlays; + +/// +/// Overlay responsible for rendering vacuum overlay. +/// +public sealed partial class GasTileVacuumOverlay : Overlay +{ + public override bool RequestScreenTexture { get; set; } = true; + private static readonly ProtoId UnshadedShader = "unshaded"; + private static readonly ProtoId VacuumOverlayShader = "VacuumDesaturation"; + private static readonly Color EmptyColor = new(0, 0, 0, 0); + private static readonly Color MarkerColor = new(255f, 0, 0); + + [Dependency] private IEntityManager _entManager = default!; + [Dependency] private IPrototypeManager _proto = default!; + [Dependency] private IClyde _clyde = default!; + [Dependency] private IConfigurationManager _configManager = default!; + + private readonly SharedMapSystem _maps; + private readonly SharedTransformSystem _xformSys; + private readonly ShaderInstance _unshader; + private readonly ShaderInstance _shader; + + private List> _intersectingGrids = new(); + private readonly OverlayResourceCache _resources = new(); + + // Overlay settings + private float _intensity; // overlay intensity. 0.0f = turned off, 1.0f = full grayscale + + public override OverlaySpace Space => OverlaySpace.WorldSpace; + + public GasTileVacuumOverlay() + { + IoCManager.InjectDependencies(this); + _maps = _entManager.System(); + _xformSys = _entManager.System(); + + _unshader = _proto.Index(UnshadedShader).Instance(); + _shader = _proto.Index(VacuumOverlayShader).InstanceUnique(); + _configManager.OnValueChanged(CCVars.VacuumOverlayIntensity, SetVacuumOverlayIntensity, invokeImmediately: true); + } + + private void SetVacuumOverlayIntensity(float intensity) + { + _intensity = MathHelper.Clamp(intensity, 0f, 1f); + _shader.SetParameter("intensity", _intensity); + } + + protected override bool BeforeDraw(in OverlayDrawArgs args) + { + if (args.MapId == MapId.Nullspace) + return false; + + var res = _resources.GetForViewport(args.Viewport, static _ => new CachedResources()); + + var target = args.Viewport.RenderTarget; + + // Probably the resolution of the game window changed, remake the textures. + if (res.VacuumTarget?.Texture.Size != target.Size) + { + res.VacuumTarget?.Dispose(); + res.VacuumTarget = _clyde.CreateRenderTarget( + target.Size, + new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), + name: $"{nameof(GasTileVacuumOverlay)}-blur"); + } + + var overlayQuery = _entManager.GetEntityQuery(); + + args.WorldHandle.UseShader(_unshader); + + var mapId = args.MapId; + var worldAABB = args.WorldAABB; + var worldBounds = args.WorldBounds; + var worldHandle = args.WorldHandle; + var worldToViewportLocal = args.Viewport.GetWorldToLocalMatrix(); + + // If there is no Vacuum after checking all visible tiles, we can bail early + var anyVacuum = false; + + // We're rendering in the context of the vacuum target texture, which will encode data as to where vacuum effect will be + args.WorldHandle.RenderInRenderTarget(res.VacuumTarget, + () => + { + _intersectingGrids.Clear(); + _maps.FindGridsIntersecting(mapId, worldAABB, ref _intersectingGrids); + foreach (var grid in _intersectingGrids) + { + if (!overlayQuery.TryGetComponent(grid.Owner, out var comp)) + continue; + + var (_, _, gridEntToWorld, worldToGridLocal) = _xformSys.GetWorldPositionRotationMatrixWithInv(grid.Owner); + var gridEntToViewportLocal = gridEntToWorld * worldToViewportLocal; + + if (!Matrix3x2.Invert(gridEntToViewportLocal, out _)) + continue; + + // Draw commands (like DrawRect) will be using grid coordinates from here + worldHandle.SetTransform(gridEntToViewportLocal); + + // We only care about tiles that fit in these bounds + var floatBounds = worldToGridLocal.TransformBox(worldBounds).Enlarged(grid.Comp.TileSize); + + var localBounds = new Box2i( + (int)MathF.Floor(floatBounds.Left), + (int)MathF.Floor(floatBounds.Bottom), + (int)MathF.Ceiling(floatBounds.Right), + (int)MathF.Ceiling(floatBounds.Top)); + + // for each tile and its gas ---> + foreach (var chunk in comp.Chunks.Values) + { + var enumerator = new GasChunkEnumerator(chunk); + + while (enumerator.MoveNext(out var tileGas)) + { + // Check and make sure the tile is within the viewport/screen + var tilePosition = chunk.Origin + (enumerator.X, enumerator.Y); + if (!localBounds.Contains(tilePosition)) + continue; + + if (tileGas.ByteGasTemperature.Value != ThermalByte.StateVacuum) + continue; + + // Encode the strength in the red channel + // alpha set to 1 as tile is active + worldHandle.DrawRect(Box2.CenteredAround(tilePosition + grid.Comp.TileSizeHalfVector, + grid.Comp.TileSizeVector), + MarkerColor); + anyVacuum = true; + } + } + } + }, + // This clears the buffer to all zero first... + EmptyColor); + + // no distortion, no need to render + if (!anyVacuum) + { + args.WorldHandle.UseShader(null); + args.WorldHandle.SetTransform(Matrix3x2.Identity); + return false; + } + + return true; + } + + protected override void Draw(in OverlayDrawArgs args) + { + var res = _resources.GetForViewport(args.Viewport, static _ => new CachedResources()); + + if (ScreenTexture is null || res.VacuumTarget is null) + return; + + _shader.SetParameter("SCREEN_TEXTURE", ScreenTexture); + + args.WorldHandle.UseShader(_shader); + args.WorldHandle.DrawTextureRect(res.VacuumTarget.Texture, args.WorldBounds); + + args.WorldHandle.UseShader(null); + args.WorldHandle.SetTransform(Matrix3x2.Identity); + } + + protected override void DisposeBehavior() + { + _resources.Dispose(); + + _configManager.UnsubValueChanged(CCVars.VacuumOverlayIntensity, SetVacuumOverlayIntensity); + base.DisposeBehavior(); + } + + internal sealed class CachedResources : IDisposable + { + public IRenderTexture? VacuumTarget; + + public void Dispose() + { + VacuumTarget?.Dispose(); + } + } +} diff --git a/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml b/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml index 55c47957a0..65104d7e9c 100644 --- a/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml +++ b/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml @@ -44,6 +44,7 @@ + _mapGridQuery; + [Robust.Shared.IoC.Dependency] private EntityQuery _gasTileOverlayQuery; + + /// + /// Per-tick cache of sessions. + /// + private readonly List _sessions = new(); + private UpdatePlayerJob _updateJob; + + private readonly Dictionary>> _lastSentChunks = new(); + + // Oh look its more duplicated decal system code! + private readonly ObjectPool> _chunkIndexPool = + new DefaultObjectPool>( + new DefaultPooledObjectPolicy>(), 64); + private readonly ObjectPool>> _chunkViewerPool = + new DefaultObjectPool>>( + new DefaultPooledObjectPolicy>>(), 64); + + private bool _doSessionUpdate; + + /// + /// Overlay update interval, in seconds. + /// + private float _updateInterval; + + private int _thresholds; + + public override void Initialize() { - [Robust.Shared.IoC.Dependency] private IGameTiming _gameTiming = default!; - [Robust.Shared.IoC.Dependency] private IPlayerManager _playerManager = default!; - [Robust.Shared.IoC.Dependency] private IParallelManager _parMan = default!; - [Robust.Shared.IoC.Dependency] private AtmosphereSystem _atmosphereSystem = default!; - [Robust.Shared.IoC.Dependency] private ChunkingSystem _chunkingSys = default!; + base.Initialize(); - [Robust.Shared.IoC.Dependency] private EntityQuery _mapGridQuery = default!; - [Robust.Shared.IoC.Dependency] private EntityQuery _gasTileOverlayQuery = default!; - - /// - /// Per-tick cache of sessions. - /// - private readonly List _sessions = new(); - private UpdatePlayerJob _updateJob; - - private readonly Dictionary>> _lastSentChunks = new(); - - // Oh look its more duplicated decal system code! - private ObjectPool> _chunkIndexPool = - new DefaultObjectPool>( - new DefaultPooledObjectPolicy>(), 64); - private ObjectPool>> _chunkViewerPool = - new DefaultObjectPool>>( - new DefaultPooledObjectPolicy>>(), 64); - - private bool _doSessionUpdate; - - /// - /// Overlay update interval, in seconds. - /// - private float _updateInterval; - - private int _thresholds; - - public override void Initialize() + _updateJob = new UpdatePlayerJob() { - base.Initialize(); + EntManager = EntityManager, + System = this, + ChunkIndexPool = _chunkIndexPool, + Sessions = _sessions, + ChunkingSys = _chunkingSys, + ChunkViewerPool = _chunkViewerPool, + LastSentChunks = _lastSentChunks, + GridQuery = _mapGridQuery, + }; - _updateJob = new UpdatePlayerJob() + _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; + + InitializeCVars(); + + SubscribeLocalEvent(Reset); + SubscribeLocalEvent(OnStartup); + } + + private void OnStartup(EntityUid uid, GasTileOverlayComponent component, ComponentStartup args) + { + // This **shouldn't** be required, but just in case we ever get entity prototypes that have gas overlays, we + // need to ensure that we send an initial full state to players. + Dirty(uid, component); + } + + public override void Shutdown() + { + base.Shutdown(); + _playerManager.PlayerStatusChanged -= OnPlayerStatusChanged; + } + + private void OnPvsToggle(bool value) + { + if (value == PvsEnabled) + return; + + PvsEnabled = value; + + if (value) + return; + + foreach (var lastSent in _lastSentChunks.Values) + { + foreach (var set in lastSent.Values) { - EntManager = EntityManager, - System = this, - ChunkIndexPool = _chunkIndexPool, - Sessions = _sessions, - ChunkingSys = _chunkingSys, - ChunkViewerPool = _chunkViewerPool, - LastSentChunks = _lastSentChunks, - GridQuery = _mapGridQuery, - }; - - _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; - - InitializeCVars(); - - SubscribeLocalEvent(Reset); - SubscribeLocalEvent(OnStartup); + set.Clear(); + _chunkIndexPool.Return(set); + } + lastSent.Clear(); } - private void OnStartup(EntityUid uid, GasTileOverlayComponent component, ComponentStartup args) + // PVS was turned off, ensure data gets sent to all clients. + var query = AllEntityQuery(); + while (query.MoveNext(out var uid, out var grid, out var meta)) { - // This **shouldn't** be required, but just in case we ever get entity prototypes that have gas overlays, we - // need to ensure that we send an initial full state to players. - Dirty(uid, component); + grid.ForceTick = _gameTiming.CurTick; + Dirty(uid, grid, meta); } + } - public override void Shutdown() + private void UpdateTickRate(float value) => _updateInterval = value > 0.0f ? 1 / value : float.MaxValue; + private void UpdateThresholds(int value) => _thresholds = value; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Invalidate(Entity grid, Vector2i index) + { + if (_gasTileOverlayQuery.Resolve(grid.Owner, ref grid.Comp)) + grid.Comp.InvalidTiles.Add(index); + } + + private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) + { + if (e.NewStatus != SessionStatus.InGame) { - base.Shutdown(); - _playerManager.PlayerStatusChanged -= OnPlayerStatusChanged; - } - - private void OnPvsToggle(bool value) - { - if (value == PvsEnabled) - return; - - PvsEnabled = value; - - if (value) - return; - - foreach (var lastSent in _lastSentChunks.Values) + if (_lastSentChunks.Remove(e.Session, out var sets)) { - foreach (var set in lastSent.Values) + foreach (var set in sets.Values) { set.Clear(); _chunkIndexPool.Return(set); } - lastSent.Clear(); - } - - // PVS was turned off, ensure data gets sent to all clients. - var query = AllEntityQuery(); - while (query.MoveNext(out var uid, out var grid, out var meta)) - { - grid.ForceTick = _gameTiming.CurTick; - Dirty(uid, grid, meta); } } - private void UpdateTickRate(float value) => _updateInterval = value > 0.0f ? 1 / value : float.MaxValue; - private void UpdateThresholds(int value) => _thresholds = value; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Invalidate(Entity grid, Vector2i index) + if (!_lastSentChunks.ContainsKey(e.Session)) { - if (_gasTileOverlayQuery.Resolve(grid.Owner, ref grid.Comp)) - grid.Comp.InvalidTiles.Add(index); + _lastSentChunks[e.Session] = new(); } + } - private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) + private byte GetOpacity(float moles, float molesVisible, float molesVisibleMax) + { + return (byte) (ContentHelpers.RoundToLevels( + MathHelper.Clamp01((moles - molesVisible) / + (molesVisibleMax - molesVisible)) * 255, byte.MaxValue, + _thresholds) * 255 / (_thresholds - 1)); + } + + public GasOverlayData GetOverlayData(GasMixture? mixture) + { + ThermalByte byteTemp; + if (mixture == null) { - if (e.NewStatus != SessionStatus.InGame) + byteTemp = new(); + byteTemp.SetVacuum(); + } + else + byteTemp = new(mixture.Temperature); + + var data = new GasOverlayData(0, new byte[VisibleGasId.Length], byteTemp); + + for (var i = 0; i < VisibleGasId.Length; i++) + { + var id = VisibleGasId[i]; + var gas = _atmosphereSystem.GetGas(id); + var moles = mixture?[id] ?? 0f; + ref var opacity = ref data.Opacity[i]; + + if (moles < gas.GasMolesVisible) { - if (_lastSentChunks.Remove(e.Session, out var sets)) - { - foreach (var set in sets.Values) - { - set.Clear(); - _chunkIndexPool.Return(set); - } - } + continue; } - if (!_lastSentChunks.ContainsKey(e.Session)) - { - _lastSentChunks[e.Session] = new(); - } - } - - private byte GetOpacity(float moles, float molesVisible, float molesVisibleMax) - { - return (byte) (ContentHelpers.RoundToLevels( - MathHelper.Clamp01((moles - molesVisible) / - (molesVisibleMax - molesVisible)) * 255, byte.MaxValue, + opacity = (byte) (ContentHelpers.RoundToLevels( + MathHelper.Clamp01((moles - gas.GasMolesVisible) / + (gas.GasMolesVisibleMax - gas.GasMolesVisible)) * 255, byte.MaxValue, _thresholds) * 255 / (_thresholds - 1)); } - public GasOverlayData GetOverlayData(GasMixture? mixture) + return data; + } + + /// + /// Updates the visuals for a tile on some grid chunk. Returns true if the visuals have changed. + /// + private bool UpdateChunkTile(GridAtmosphereComponent gridAtmosphere, GasOverlayChunk chunk, Vector2i index) + { + ref var oldData = ref chunk.TileData[chunk.GetDataIndex(index)]; + if (!gridAtmosphere.Tiles.TryGetValue(index, out var tile)) { - ThermalByte byteTemp; - if (mixture == null) - { - byteTemp = new(); - byteTemp.SetVacuum(); - } - else - byteTemp = new(mixture.Temperature); + if (oldData.Equals(default)) + return false; - var data = new GasOverlayData(0, new byte[VisibleGasId.Length], byteTemp); + chunk.LastUpdate = _gameTiming.CurTick; + oldData = default; + return true; + } + var changed = false; + + ThermalByte newByteTemp = new(); + + if (tile.Hotspot.Valid) + newByteTemp.SetTemperature(tile.Hotspot.Temperature); + else if (!tile.Space && tile.Air?.TotalMoles <= ThermalByte.MinimalPressureThreshold) + newByteTemp.SetVacuum(); + else if (!tile.Space && tile.Air != null) + newByteTemp = new(tile.Air.Temperature); + + if (oldData.Equals(default)) + { + changed = true; + oldData = new GasOverlayData(tile.Hotspot.State, new byte[VisibleGasId.Length], newByteTemp); + } + else if (oldData.FireState != tile.Hotspot.State || + Math.Abs(oldData.ByteGasTemperature.Value - newByteTemp.Value) > 1 || // Dirty Temperature when there is more then 1 byte difference. That should measure up to minimum 4 degreese difference, 6 degreese on average. + oldData.ByteGasTemperature.Value != newByteTemp.Value && newByteTemp.Value > ThermalByte.TempResolution) // change of special ThermalByte value + { + changed = true; + oldData = new GasOverlayData(tile.Hotspot.State, oldData.Opacity, newByteTemp); + } + + if (tile is {Air: not null, NoGridTile: false}) + { for (var i = 0; i < VisibleGasId.Length; i++) { var id = VisibleGasId[i]; var gas = _atmosphereSystem.GetGas(id); - var moles = mixture?[id] ?? 0f; - ref var opacity = ref data.Opacity[i]; + var moles = tile.Air[id]; + ref var oldOpacity = ref oldData.Opacity[i]; if (moles < gas.GasMolesVisible) { + if (oldOpacity != 0) + { + oldOpacity = 0; + changed = true; + } + continue; } - opacity = (byte) (ContentHelpers.RoundToLevels( - MathHelper.Clamp01((moles - gas.GasMolesVisible) / - (gas.GasMolesVisibleMax - gas.GasMolesVisible)) * 255, byte.MaxValue, - _thresholds) * 255 / (_thresholds - 1)); - } + var opacity = GetOpacity(moles, gas.GasMolesVisible, gas.GasMolesVisibleMax); - return data; + if (oldOpacity == opacity) + continue; + + oldOpacity = opacity; + changed = true; + } + } + else + { + for (var i = 0; i < VisibleGasId.Length; i++) + { + changed |= oldData.Opacity[i] != 0; + oldData.Opacity[i] = 0; + } } - /// - /// Updates the visuals for a tile on some grid chunk. Returns true if the visuals have changed. - /// - private bool UpdateChunkTile(GridAtmosphereComponent gridAtmosphere, GasOverlayChunk chunk, Vector2i index) + if (!changed) + return false; + + chunk.LastUpdate = _gameTiming.CurTick; + return true; + } + + private void UpdateOverlayData() + { + // TODO parallelize? + var query = AllEntityQuery(); + while (query.MoveNext(out var uid, out var overlay, out var gam, out var meta)) { - ref var oldData = ref chunk.TileData[chunk.GetDataIndex(index)]; - if (!gridAtmosphere.Tiles.TryGetValue(index, out var tile)) - { - if (oldData.Equals(default)) - return false; - - chunk.LastUpdate = _gameTiming.CurTick; - oldData = default; - return true; - } - var changed = false; - - ThermalByte newByteTemp = new(); - - if (tile.Hotspot.Valid) - newByteTemp.SetTemperature(tile.Hotspot.Temperature); - else if (!tile.Space && tile.Air?.TotalMoles <= 5f) - newByteTemp.SetVacuum(); - else if (!tile.Space && tile.Air != null) - newByteTemp = new(tile.Air.Temperature); - - if (oldData.Equals(default)) + foreach (var index in overlay.InvalidTiles) { - changed = true; - oldData = new GasOverlayData(tile.Hotspot.State, new byte[VisibleGasId.Length], newByteTemp); - } - else if (oldData.FireState != tile.Hotspot.State || - Math.Abs(oldData.ByteGasTemperature.Value - newByteTemp.Value) > 1 || // Dirty Temperature when there is more then 1 byte difference. That should measure up to minimum 4 degreese difference, 6 degreese on average. - (oldData.ByteGasTemperature.Value != newByteTemp.Value && newByteTemp.Value > ThermalByte.TempResolution)) // change of special ThermalByte value - { - changed = true; - oldData = new GasOverlayData(tile.Hotspot.State, oldData.Opacity, newByteTemp); + var chunkIndex = GetGasChunkIndices(index); + + if (!overlay.Chunks.TryGetValue(chunkIndex, out var chunk)) + overlay.Chunks[chunkIndex] = chunk = new GasOverlayChunk(chunkIndex); + + changed |= UpdateChunkTile(gam, chunk, index); } - if (tile is {Air: not null, NoGridTile: false}) - { - for (var i = 0; i < VisibleGasId.Length; i++) - { - var id = VisibleGasId[i]; - var gas = _atmosphereSystem.GetGas(id); - var moles = tile.Air[id]; - ref var oldOpacity = ref oldData.Opacity[i]; + if (changed) + Dirty(uid, overlay, meta); - if (moles < gas.GasMolesVisible) - { - if (oldOpacity != 0) - { - oldOpacity = 0; - changed = true; - } - - continue; - } - - var opacity = GetOpacity(moles, gas.GasMolesVisible, gas.GasMolesVisibleMax); - - if (oldOpacity == opacity) - continue; - - oldOpacity = opacity; - changed = true; - } - } - else - { - for (var i = 0; i < VisibleGasId.Length; i++) - { - changed |= oldData.Opacity[i] != 0; - oldData.Opacity[i] = 0; - } - } - - if (!changed) - return false; - - chunk.LastUpdate = _gameTiming.CurTick; - return true; - } - - private void UpdateOverlayData() - { - // TODO parallelize? - var query = AllEntityQuery(); - while (query.MoveNext(out var uid, out var overlay, out var gam, out var meta)) - { - var changed = false; - foreach (var index in overlay.InvalidTiles) - { - var chunkIndex = GetGasChunkIndices(index); - - if (!overlay.Chunks.TryGetValue(chunkIndex, out var chunk)) - overlay.Chunks[chunkIndex] = chunk = new GasOverlayChunk(chunkIndex); - - changed |= UpdateChunkTile(gam, chunk, index); - } - - if (changed) - Dirty(uid, overlay, meta); - - overlay.InvalidTiles.Clear(); - } - } - - public override void Update(float frameTime) - { - base.Update(frameTime); - AccumulatedFrameTime += frameTime; - - if (_doSessionUpdate) - { - UpdateSessions(); - return; - } - - if (AccumulatedFrameTime < _updateInterval) - return; - - AccumulatedFrameTime -= _updateInterval; - - // First, update per-chunk visual data for any invalidated tiles. - UpdateOverlayData(); - - // Then, next tick we send the data to players. - // This is to avoid doing all the work in the same tick. - _doSessionUpdate = true; - } - - public void UpdateSessions() - { - _doSessionUpdate = false; - - if (!PvsEnabled) - return; - - // Now we'll go through each player, then through each chunk in range of that player checking if the player is still in range - // If they are, check if they need the new data to send (i.e. if there's an overlay for the gas). - // Afterwards we reset all the chunk data for the next time we tick. - _sessions.Clear(); - - foreach (var player in _playerManager.Sessions) - { - if (player.Status != SessionStatus.InGame) - continue; - - _sessions.Add(player); - } - - if (_sessions.Count == 0) - return; - - _parMan.ProcessNow(_updateJob, _sessions.Count); - _updateJob.LastSessionUpdate = _gameTiming.CurTick; - } - - public void Reset(RoundRestartCleanupEvent ev) - { - foreach (var data in _lastSentChunks.Values) - { - foreach (var previous in data.Values) - { - previous.Clear(); - _chunkIndexPool.Return(previous); - } - - data.Clear(); - } - } - - #region Jobs - - /// - /// Updates per player gas overlay data. - /// - private record struct UpdatePlayerJob : IParallelRobustJob - { - public int BatchSize => 2; - - public IEntityManager EntManager; - public ChunkingSystem ChunkingSys; - public GasTileOverlaySystem System; - public ObjectPool> ChunkIndexPool; - public ObjectPool>> ChunkViewerPool; - - public GameTick LastSessionUpdate; - public Dictionary>> LastSentChunks; - public List Sessions; - - public EntityQuery GridQuery; - - public void Execute(int index) - { - var playerSession = Sessions[index]; - var chunksInRange = ChunkingSys.GetChunksForSession(playerSession, ChunkSize, ChunkIndexPool, ChunkViewerPool); - var previouslySent = LastSentChunks[playerSession]; - - var ev = new GasOverlayUpdateEvent(); - - foreach (var (netGrid, oldIndices) in previouslySent) - { - // Mark the whole grid as stale and flag for removal. - if (!chunksInRange.TryGetValue(netGrid, out var chunks)) - { - previouslySent.Remove(netGrid); - - // If grid was deleted then don't worry about sending it to the client. - if (!EntManager.TryGetEntity(netGrid, out var gridId) || GridQuery.HasComp(gridId.Value)) - ev.RemovedChunks[netGrid] = oldIndices; - else - { - oldIndices.Clear(); - ChunkIndexPool.Return(oldIndices); - } - - continue; - } - - var old = ChunkIndexPool.Get(); - DebugTools.Assert(old.Count == 0); - foreach (var chunk in oldIndices) - { - if (!chunks.Contains(chunk)) - old.Add(chunk); - } - - if (old.Count == 0) - ChunkIndexPool.Return(old); - else - ev.RemovedChunks.Add(netGrid, old); - } - - foreach (var (netGrid, gridChunks) in chunksInRange) - { - // Not all grids have atmospheres. - if (!EntManager.TryGetEntity(netGrid, out var grid) || !EntManager.TryGetComponent(grid, out GasTileOverlayComponent? overlay)) - continue; - - List dataToSend = new(); - ev.UpdatedChunks[netGrid] = dataToSend; - - previouslySent.TryGetValue(netGrid, out var previousChunks); - - foreach (var gIndex in gridChunks) - { - if (!overlay.Chunks.TryGetValue(gIndex, out var value)) - continue; - - // If the chunk was updated since we last sent it, send it again - if (value.LastUpdate > LastSessionUpdate) - { - dataToSend.Add(value); - continue; - } - - // Always send it if we didn't previously send it - if (previousChunks == null || !previousChunks.Contains(gIndex)) - dataToSend.Add(value); - } - - previouslySent[netGrid] = gridChunks; - if (previousChunks != null) - { - previousChunks.Clear(); - ChunkIndexPool.Return(previousChunks); - } - } - - if (ev.UpdatedChunks.Count != 0 || ev.RemovedChunks.Count != 0) - System.RaiseNetworkEvent(ev, playerSession.Channel); - } - } - - #endregion - - private void InitializeCVars() - { - Subs.CVar(ConfMan, CCVars.NetGasOverlayTickRate, UpdateTickRate, true); - Subs.CVar(ConfMan, CCVars.GasOverlayThresholds, UpdateThresholds, true); - Subs.CVar(ConfMan, CVars.NetPVS, OnPvsToggle, true); + overlay.InvalidTiles.Clear(); } } + + public override void Update(float frameTime) + { + base.Update(frameTime); + AccumulatedFrameTime += frameTime; + + if (_doSessionUpdate) + { + UpdateSessions(); + return; + } + + if (AccumulatedFrameTime < _updateInterval) + return; + + AccumulatedFrameTime -= _updateInterval; + + // First, update per-chunk visual data for any invalidated tiles. + UpdateOverlayData(); + + // Then, next tick we send the data to players. + // This is to avoid doing all the work in the same tick. + _doSessionUpdate = true; + } + + public void UpdateSessions() + { + _doSessionUpdate = false; + + if (!PvsEnabled) + return; + + // Now we'll go through each player, then through each chunk in range of that player checking if the player is still in range + // If they are, check if they need the new data to send (i.e. if there's an overlay for the gas). + // Afterwards we reset all the chunk data for the next time we tick. + _sessions.Clear(); + + foreach (var player in _playerManager.Sessions) + { + if (player.Status != SessionStatus.InGame) + continue; + + _sessions.Add(player); + } + + if (_sessions.Count == 0) + return; + + _parMan.ProcessNow(_updateJob, _sessions.Count); + _updateJob.LastSessionUpdate = _gameTiming.CurTick; + } + + public void Reset(RoundRestartCleanupEvent ev) + { + foreach (var data in _lastSentChunks.Values) + { + foreach (var previous in data.Values) + { + previous.Clear(); + _chunkIndexPool.Return(previous); + } + + data.Clear(); + } + } + + #region Jobs + + /// + /// Updates per player gas overlay data. + /// + private record struct UpdatePlayerJob : IParallelRobustJob + { + public int BatchSize => 2; + + public IEntityManager EntManager; + public ChunkingSystem ChunkingSys; + public GasTileOverlaySystem System; + public ObjectPool> ChunkIndexPool; + public ObjectPool>> ChunkViewerPool; + + public GameTick LastSessionUpdate; + public Dictionary>> LastSentChunks; + public List Sessions; + + public EntityQuery GridQuery; + + public void Execute(int index) + { + var playerSession = Sessions[index]; + var chunksInRange = ChunkingSys.GetChunksForSession(playerSession, ChunkSize, ChunkIndexPool, ChunkViewerPool); + var previouslySent = LastSentChunks[playerSession]; + + var ev = new GasOverlayUpdateEvent(); + + foreach (var (netGrid, oldIndices) in previouslySent) + { + // Mark the whole grid as stale and flag for removal. + if (!chunksInRange.TryGetValue(netGrid, out var chunks)) + { + previouslySent.Remove(netGrid); + + // If grid was deleted then don't worry about sending it to the client. + if (!EntManager.TryGetEntity(netGrid, out var gridId) || GridQuery.HasComp(gridId.Value)) + ev.RemovedChunks[netGrid] = oldIndices; + else + { + oldIndices.Clear(); + ChunkIndexPool.Return(oldIndices); + } + + continue; + } + + var old = ChunkIndexPool.Get(); + DebugTools.Assert(old.Count == 0); + foreach (var chunk in oldIndices) + { + if (!chunks.Contains(chunk)) + old.Add(chunk); + } + + if (old.Count == 0) + ChunkIndexPool.Return(old); + else + ev.RemovedChunks.Add(netGrid, old); + } + + foreach (var (netGrid, gridChunks) in chunksInRange) + { + // Not all grids have atmospheres. + if (!EntManager.TryGetEntity(netGrid, out var grid) || !EntManager.TryGetComponent(grid, out GasTileOverlayComponent? overlay)) + continue; + + List dataToSend = new(); + ev.UpdatedChunks[netGrid] = dataToSend; + + previouslySent.TryGetValue(netGrid, out var previousChunks); + + foreach (var gIndex in gridChunks) + { + if (!overlay.Chunks.TryGetValue(gIndex, out var value)) + continue; + + // If the chunk was updated since we last sent it, send it again + if (value.LastUpdate > LastSessionUpdate) + { + dataToSend.Add(value); + continue; + } + + // Always send it if we didn't previously send it + if (previousChunks == null || !previousChunks.Contains(gIndex)) + dataToSend.Add(value); + } + + previouslySent[netGrid] = gridChunks; + if (previousChunks != null) + { + previousChunks.Clear(); + ChunkIndexPool.Return(previousChunks); + } + } + + if (ev.UpdatedChunks.Count != 0 || ev.RemovedChunks.Count != 0) + System.RaiseNetworkEvent(ev, playerSession.Channel); + } + } + + #endregion + + private void InitializeCVars() + { + Subs.CVar(ConfMan, CCVars.NetGasOverlayTickRate, UpdateTickRate, true); + Subs.CVar(ConfMan, CCVars.GasOverlayThresholds, UpdateThresholds, true); + Subs.CVar(ConfMan, CVars.NetPVS, OnPvsToggle, true); + } } diff --git a/Content.Shared/Atmos/EntitySystems/SharedGasTileOverlaySystem.cs b/Content.Shared/Atmos/EntitySystems/SharedGasTileOverlaySystem.cs index ad3dec38c7..d3e6c818a0 100644 --- a/Content.Shared/Atmos/EntitySystems/SharedGasTileOverlaySystem.cs +++ b/Content.Shared/Atmos/EntitySystems/SharedGasTileOverlaySystem.cs @@ -162,6 +162,11 @@ public struct ThermalByte : IEquatable public const float TempDegreeResolution = (TempMaximum - TempMinimum) / TempResolution; public const float TempToByteFactor = TempResolution / (TempMaximum - TempMinimum); + /// + /// The minimum pressure gas must have for tile temperature to be networked to clients. Below this threshold thermal byte is set to StateVacuum. + /// + public const float MinimalPressureThreshold = 20f; + private byte _coreValue; public ThermalByte(float temperatureKelvin) diff --git a/Content.Shared/CCVar/CCVars.Accessibility.Motion.cs b/Content.Shared/CCVar/CCVars.Accessibility.Motion.cs index 65b33da7ce..cbc68788e3 100644 --- a/Content.Shared/CCVar/CCVars.Accessibility.Motion.cs +++ b/Content.Shared/CCVar/CCVars.Accessibility.Motion.cs @@ -43,6 +43,18 @@ public sealed partial class CCVars public static readonly CVarDef DisableFlashEffect = CVarDef.Create("accessibility.disable_flash_effect", false, CVar.CLIENTONLY | CVar.ARCHIVE); + /// + /// Decreases the intensity of the vacuum overlay 1.0f is maximum. + /// + public static readonly CVarDef VacuumOverlayIntensity = + CVarDef.Create("accessibility.vacuum_overlay_intensity", 0.5f, CVar.CLIENTONLY | CVar.ARCHIVE); + + /// + /// Toggles vacuum overlay serverside. If you do not want it as an option for your players use this. + /// + public static readonly CVarDef VacuumOverlay = + CVarDef.Create("accessibility.vacuum_overlay", true, CVar.ARCHIVE | CVar.REPLICATED | CVar.SERVER); + /// /// Decreases motion on the heat distortion shader. /// diff --git a/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl b/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl index 5996e122b8..27a1cbfadd 100644 --- a/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl +++ b/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl @@ -403,6 +403,7 @@ ui-options-chat-window-opacity = Chat window opacity ui-options-speech-bubble-text-opacity = Speech bubble text opacity ui-options-speech-bubble-speaker-opacity = Speech bubble speaker opacity ui-options-speech-bubble-background-opacity = Speech bubble background opacity +ui-options-vacuum-overlay-intensity = Vacuum overlay intensity ui-options-censor-nudity = Censor character nudity diff --git a/Resources/Prototypes/Shaders/shaders.yml b/Resources/Prototypes/Shaders/shaders.yml index 8758d96472..88bb31916e 100644 --- a/Resources/Prototypes/Shaders/shaders.yml +++ b/Resources/Prototypes/Shaders/shaders.yml @@ -140,3 +140,8 @@ id: Sharpness kind: source path: "/Textures/Shaders/sharpness.swsl" + +- type: shader + id: VacuumDesaturation + kind: source + path: "/Textures/Shaders/vacuum_desaturation.swsl" diff --git a/Resources/Textures/Shaders/vacuum_desaturation.swsl b/Resources/Textures/Shaders/vacuum_desaturation.swsl new file mode 100644 index 0000000000..bdcacd8460 --- /dev/null +++ b/Resources/Textures/Shaders/vacuum_desaturation.swsl @@ -0,0 +1,14 @@ +uniform sampler2D SCREEN_TEXTURE; +uniform mediump float intensity; + +void fragment() +{ + if (texture2D(TEXTURE, UV).r < 0.1) + discard; + + mediump vec4 screen_pixel = texture2D(SCREEN_TEXTURE, UV); + mediump float gray = dot(screen_pixel.rgb, vec3(0.299, 0.587, 0.114)); + + screen_pixel.rgb = mix(screen_pixel.rgb, vec3(gray), intensity); + COLOR = screen_pixel; +}