Skip to content

Commit dd1c33e

Browse files
Quantum-crossfunkystationbot
andauthored
Add heat distortion shader for hot gases (Upstream pending PR port) (funky-station#1366)
Co-authored-by: funkystationbot <funky@funkystation.org>
1 parent 9db775d commit dd1c33e

6 files changed

Lines changed: 364 additions & 5 deletions

File tree

Content.Client/Atmos/EntitySystems/GasTileOverlaySystem.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// SPDX-FileCopyrightText: 2023 metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
1515
// SPDX-FileCopyrightText: 2024 Aidenkrz <aiden@djkraz.com>
1616
// SPDX-FileCopyrightText: 2024 Leon Friedrich <60421075+ElectroJr@users.noreply.github.com>
17+
// SPDX-FileCopyrightText: 2025 Quantum-cross <7065792+Quantum-cross@users.noreply.github.com>
1718
// SPDX-FileCopyrightText: 2025 Tay <td12233a@gmail.com>
1819
// SPDX-FileCopyrightText: 2025 slarticodefast <161409025+slarticodefast@users.noreply.github.com>
1920
// SPDX-FileCopyrightText: 2025 taydeo <td12233a@gmail.com>
@@ -41,6 +42,7 @@ public sealed class GasTileOverlaySystem : SharedGasTileOverlaySystem
4142
[Dependency] private readonly SharedTransformSystem _xformSys = default!;
4243

4344
private GasTileOverlay _overlay = default!;
45+
private GasTileHeatOverlay _heatOverlay = default!;
4446

4547
public override void Initialize()
4648
{
@@ -50,12 +52,16 @@ public override void Initialize()
5052

5153
_overlay = new GasTileOverlay(this, EntityManager, _resourceCache, ProtoMan, _spriteSys, _xformSys);
5254
_overlayMan.AddOverlay(_overlay);
55+
56+
_heatOverlay = new GasTileHeatOverlay();
57+
_overlayMan.AddOverlay(_heatOverlay);
5358
}
5459

5560
public override void Shutdown()
5661
{
5762
base.Shutdown();
5863
_overlayMan.RemoveOverlay<GasTileOverlay>();
64+
_overlayMan.RemoveOverlay<GasTileHeatOverlay>();
5965
}
6066

6167
private void OnHandleState(EntityUid gridUid, GasTileOverlayComponent comp, ref ComponentHandleState args)
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// SPDX-FileCopyrightText: 2025 Quantum-cross <7065792+Quantum-cross@users.noreply.github.com>
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
using System.Numerics;
6+
using Content.Shared.Atmos;
7+
using Content.Shared.Atmos.Components;
8+
using Content.Shared.Atmos.EntitySystems;
9+
using Content.Shared.CCVar;
10+
using Robust.Client.Graphics;
11+
using Robust.Shared.Configuration;
12+
using Robust.Shared.Enums;
13+
using Robust.Shared.Map;
14+
using Robust.Shared.Map.Components;
15+
using Robust.Shared.Prototypes;
16+
17+
namespace Content.Client.Atmos.Overlays;
18+
19+
public sealed class GasTileHeatOverlay : Overlay
20+
{
21+
public override bool RequestScreenTexture { get; set; } = true;
22+
private static readonly ProtoId<ShaderPrototype> UnshadedShader = "unshaded";
23+
private static readonly ProtoId<ShaderPrototype> HeatOverlayShader = "Heat";
24+
25+
[Dependency] private readonly IEntityManager _entManager = default!;
26+
[Dependency] private readonly IMapManager _mapManager = default!;
27+
[Dependency] private readonly IPrototypeManager _proto = default!;
28+
[Dependency] private readonly IClyde _clyde = default!;
29+
[Dependency] private readonly IConfigurationManager _configManager = default!;
30+
private readonly SharedTransformSystem _xformSys;
31+
32+
private IRenderTexture? _heatTarget;
33+
private IRenderTexture? _heatBlurTarget;
34+
35+
public override OverlaySpace Space => OverlaySpace.WorldSpace;
36+
private readonly ShaderInstance _shader;
37+
38+
public GasTileHeatOverlay()
39+
{
40+
IoCManager.InjectDependencies(this);
41+
_xformSys = _entManager.System<SharedTransformSystem>();
42+
43+
_shader = _proto.Index(HeatOverlayShader).InstanceUnique();
44+
45+
_configManager.OnValueChanged(CCVars.ReducedMotion, SetReducedMotion, invokeImmediately: true);
46+
47+
}
48+
49+
private void SetReducedMotion(bool reducedMotion)
50+
{
51+
_shader.SetParameter("strength_scale", reducedMotion ? 0.5f : 1f);
52+
_shader.SetParameter("speed_scale", reducedMotion ? 0.25f : 1f);
53+
}
54+
55+
protected override bool BeforeDraw(in OverlayDrawArgs args)
56+
{
57+
if (args.MapId == MapId.Nullspace)
58+
return false;
59+
60+
var target = args.Viewport.RenderTarget;
61+
62+
// Probably the resolution of the game window changed, remake the textures.
63+
if (_heatTarget?.Texture.Size != target.Size)
64+
{
65+
_heatTarget?.Dispose();
66+
_heatTarget = _clyde.CreateRenderTarget(
67+
target.Size,
68+
new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb),
69+
name: nameof(GasTileHeatOverlay));
70+
}
71+
if (_heatBlurTarget?.Texture.Size != target.Size)
72+
{
73+
_heatBlurTarget?.Dispose();
74+
_heatBlurTarget = _clyde.CreateRenderTarget(
75+
target.Size,
76+
new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb),
77+
name: $"{nameof(GasTileHeatOverlay)}-blur");
78+
}
79+
80+
var overlayQuery = _entManager.GetEntityQuery<GasTileOverlayComponent>();
81+
82+
args.WorldHandle.UseShader(_proto.Index(UnshadedShader).Instance());
83+
84+
var mapId = args.MapId;
85+
var worldAABB = args.WorldAABB;
86+
var worldBounds = args.WorldBounds;
87+
var worldHandle = args.WorldHandle;
88+
var worldToViewportLocal = args.Viewport.GetWorldToLocalMatrix();
89+
90+
// If there is no distortion after checking all visible tiles, we can bail early
91+
var anyDistortion = false;
92+
93+
// We're rendering in the context of the heat target texture, which will encode data as to where and how strong
94+
// the heat distortion will be
95+
args.WorldHandle.RenderInRenderTarget(_heatTarget,
96+
() =>
97+
{
98+
List<Entity<MapGridComponent>> grids = new();
99+
_mapManager.FindGridsIntersecting(mapId, worldAABB, ref grids);
100+
foreach (var grid in grids)
101+
{
102+
if (!overlayQuery.TryGetComponent(grid.Owner, out var comp))
103+
continue;
104+
105+
var gridEntToWorld = _xformSys.GetWorldMatrix(grid.Owner);
106+
var gridEntToViewportLocal = gridEntToWorld * worldToViewportLocal;
107+
108+
if (!Matrix3x2.Invert(gridEntToViewportLocal, out var viewportLocalToGridEnt))
109+
continue;
110+
111+
var uvToUi = Matrix3Helpers.CreateScale(_heatTarget.Size.X, -_heatTarget.Size.Y);
112+
var uvToGridEnt = uvToUi * viewportLocalToGridEnt;
113+
114+
// Because we want the actual distortion to be calculated based on the grid coordinates*, we need
115+
// to pass a matrix transformation to go from the viewport coordinates to grid coordinates.
116+
// * (why? because otherwise the effect would shimmer like crazy as you moved around, think
117+
// moving a piece of warped glass above a picture instead of placing the warped glass on the
118+
// paper and moving them together)
119+
_shader.SetParameter("grid_ent_from_viewport_local", uvToGridEnt);
120+
121+
// Draw commands (like DrawRect) will be using grid coordinates from here
122+
worldHandle.SetTransform(gridEntToViewportLocal);
123+
124+
// We only care about tiles that fit in these bounds
125+
var floatBounds = worldToViewportLocal.TransformBox(worldBounds).Enlarged(grid.Comp.TileSize);
126+
var localBounds = new Box2i(
127+
(int) MathF.Floor(floatBounds.Left),
128+
(int) MathF.Floor(floatBounds.Bottom),
129+
(int) MathF.Ceiling(floatBounds.Right),
130+
(int) MathF.Ceiling(floatBounds.Top));
131+
132+
// for each tile and its gas --->
133+
foreach (var chunk in comp.Chunks.Values)
134+
{
135+
var enumerator = new GasChunkEnumerator(chunk);
136+
137+
while (enumerator.MoveNext(out var tileGas))
138+
{
139+
// --->
140+
// Check and make sure the tile is within the viewport/screen
141+
var tilePosition = chunk.Origin + (enumerator.X, enumerator.Y);
142+
if (!localBounds.Contains(tilePosition))
143+
continue;
144+
145+
// Get the distortion strength from the temperature and bail if it's not hot enough
146+
var strength = SharedGasTileOverlaySystem.GetHeatDistortionStrength(tileGas.Temperature);
147+
if (strength <= 0f)
148+
continue;
149+
150+
anyDistortion = true;
151+
// Encode the strength in the red channel, then 1.0 alpha if it's an active tile.
152+
// BlurRenderTarget will then apply a blur around the edge, but we don't want it to bleed
153+
// past the tile.
154+
// So we use this alpha channel to chop the lower alpha values off in the shader to fit a
155+
// fit mask back into the tile.
156+
worldHandle.DrawRect(
157+
Box2.CenteredAround(tilePosition + new Vector2(0.5f, 0.5f), grid.Comp.TileSizeVector),
158+
new Color(strength,0f, 0f, strength > 0f ? 1.0f : 0f));
159+
}
160+
}
161+
}
162+
},
163+
// This clears the buffer to all zero first...
164+
new Color(0, 0, 0, 0));
165+
166+
// no distortion, no need to render
167+
if (!anyDistortion)
168+
{
169+
// Return the draw handle to normal settings
170+
args.WorldHandle.UseShader(null);
171+
args.WorldHandle.SetTransform(Matrix3x2.Identity);
172+
return false;
173+
}
174+
175+
// Clear to draw
176+
return true;
177+
}
178+
179+
protected override void Draw(in OverlayDrawArgs args)
180+
{
181+
if (ScreenTexture is null || _heatTarget is null || _heatBlurTarget is null)
182+
return;
183+
184+
// Blur to soften the edges of the distortion. the lower parts of the alpha channel need to get cut off in the
185+
// distortion shader to keep them in tile bounds.
186+
_clyde.BlurRenderTarget(args.Viewport, _heatTarget, _heatBlurTarget, args.Viewport.Eye!, 14f);
187+
188+
// Set up and render the distortion
189+
_shader.SetParameter("SCREEN_TEXTURE", ScreenTexture);
190+
args.WorldHandle.UseShader(_shader);
191+
args.WorldHandle.DrawTextureRect(_heatTarget.Texture, args.WorldBounds);
192+
193+
// Return the draw handle to normal settings
194+
args.WorldHandle.UseShader(null);
195+
args.WorldHandle.SetTransform(Matrix3x2.Identity);
196+
}
197+
198+
protected override void DisposeBehavior()
199+
{
200+
_heatTarget = null;
201+
_heatBlurTarget = null;
202+
_configManager.UnsubValueChanged(CCVars.ReducedMotion, SetReducedMotion);
203+
base.DisposeBehavior();
204+
}
205+
}

Content.Server/Atmos/EntitySystems/GasTileOverlaySystem.cs

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// SPDX-FileCopyrightText: 2024 Leon Friedrich <60421075+ElectroJr@users.noreply.github.com>
1515
// SPDX-FileCopyrightText: 2024 Pieter-Jan Briers <pieterjan.briers+git@gmail.com>
1616
// SPDX-FileCopyrightText: 2024 Tayrtahn <tayrtahn@gmail.com>
17+
// SPDX-FileCopyrightText: 2025 Quantum-cross <7065792+Quantum-cross@users.noreply.github.com>
1718
// SPDX-FileCopyrightText: 2025 taydeo <td12233a@gmail.com>
1819
//
1920
// SPDX-License-Identifier: MIT
@@ -82,6 +83,12 @@ public sealed class GasTileOverlaySystem : SharedGasTileOverlaySystem
8283
private int _thresholds;
8384
private EntityQuery<GasTileOverlayComponent> _query;
8485

86+
/// <summary>
87+
/// How much the distortion strength should change for the temperature of a tile to be dirtied.
88+
/// The strength goes from 0.0f to 1.0f, so 0.05f gives it essentially 20 "steps"
89+
/// </summary>
90+
private const float HeatDistortionStrengthChangeTolerance = 0.05f;
91+
8592
public override void Initialize()
8693
{
8794
base.Initialize();
@@ -190,7 +197,9 @@ private byte GetOpacity(float moles, float molesVisible, float molesVisibleMax)
190197

191198
public GasOverlayData GetOverlayData(GasMixture? mixture)
192199
{
193-
var data = new GasOverlayData(0, new byte[VisibleGasId.Length]);
200+
var data = new GasOverlayData(0,
201+
new byte[VisibleGasId.Length],
202+
mixture?.Temperature ?? Atmospherics.T20C);
194203

195204
for (var i = 0; i < VisibleGasId.Length; i++)
196205
{
@@ -230,15 +239,17 @@ private bool UpdateChunkTile(GridAtmosphereComponent gridAtmosphere, GasOverlayC
230239
}
231240

232241
var changed = false;
242+
var temp = tile.Hotspot.Valid ? tile.Hotspot.Temperature : tile.Air?.Temperature ?? Atmospherics.TCMB;
233243
if (oldData.Equals(default))
234244
{
235245
changed = true;
236-
oldData = new GasOverlayData(tile.Hotspot.State, new byte[VisibleGasId.Length]);
246+
oldData = new GasOverlayData(tile.Hotspot.State, new byte[VisibleGasId.Length], temp);
237247
}
238-
else if (oldData.FireState != tile.Hotspot.State)
248+
else if (oldData.FireState != tile.Hotspot.State ||
249+
CheckTemperatureTolerance(oldData.Temperature, temp, HeatDistortionStrengthChangeTolerance))
239250
{
240251
changed = true;
241-
oldData = new GasOverlayData(tile.Hotspot.State, oldData.Opacity);
252+
oldData = new GasOverlayData(tile.Hotspot.State, oldData.Opacity, temp);
242253
}
243254

244255
if (tile is {Air: not null, NoGridTile: false})
@@ -286,6 +297,20 @@ private bool UpdateChunkTile(GridAtmosphereComponent gridAtmosphere, GasOverlayC
286297
return true;
287298
}
288299

300+
/// <summary>
301+
/// This function determines whether the change in temperature is significant enough to warrant dirtying the tile data.
302+
/// </summary>
303+
private static bool CheckTemperatureTolerance(float tempA, float tempB, float tolerance)
304+
{
305+
var (strengthA, strengthB) = (GetHeatDistortionStrength(tempA), GetHeatDistortionStrength(tempB));
306+
307+
return (strengthA <= 0f && strengthB > 0f) || // change to or from 0
308+
(strengthB <= 0f && strengthA > 0f) ||
309+
(strengthA >= 1f && strengthB < 1f) || // change to or from 1
310+
(strengthB >= 1f && strengthA < 1f) ||
311+
Math.Abs(strengthA - strengthB) > tolerance; // other change within tolerance
312+
}
313+
289314
private void UpdateOverlayData()
290315
{
291316
// TODO parallelize?

Content.Shared/Atmos/EntitySystems/SharedGasTileOverlaySystem.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// SPDX-FileCopyrightText: 2023 metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
1111
// SPDX-FileCopyrightText: 2024 Aidenkrz <aiden@djkraz.com>
1212
// SPDX-FileCopyrightText: 2024 Leon Friedrich <60421075+ElectroJr@users.noreply.github.com>
13+
// SPDX-FileCopyrightText: 2025 Quantum-cross <7065792+Quantum-cross@users.noreply.github.com>
1314
// SPDX-FileCopyrightText: 2025 taydeo <td12233a@gmail.com>
1415
//
1516
// SPDX-License-Identifier: MIT
@@ -24,6 +25,11 @@ namespace Content.Shared.Atmos.EntitySystems
2425
{
2526
public abstract class SharedGasTileOverlaySystem : EntitySystem
2627
{
28+
private const float TempAtMinHeatDistortion = 325.0f;
29+
private const float TempAtMaxHeatDistortion = 1000.0f;
30+
private const float HeatDistortionSlope = 1.0f / (TempAtMaxHeatDistortion - TempAtMinHeatDistortion);
31+
private const float HeatDistortionIntercept = -TempAtMinHeatDistortion * HeatDistortionSlope;
32+
2733
public const byte ChunkSize = 8;
2834
protected float AccumulatedFrameTime;
2935
protected bool PvsEnabled;
@@ -88,14 +94,18 @@ public static Vector2i GetGasChunkIndices(Vector2i indices)
8894
[ViewVariables]
8995
public readonly byte[] Opacity;
9096

97+
[ViewVariables]
98+
public readonly float Temperature;
99+
91100
// TODO change fire color based on temps
92101
// But also: dont dirty on a 0.01 kelvin change in temperatures.
93102
// Either have a temp tolerance, or map temperature -> byte levels
94103

95-
public GasOverlayData(byte fireState, byte[] opacity)
104+
public GasOverlayData(byte fireState, byte[] opacity, float temperature)
96105
{
97106
FireState = fireState;
98107
Opacity = opacity;
108+
Temperature = temperature;
99109
}
100110

101111
public bool Equals(GasOverlayData other)
@@ -115,10 +125,24 @@ public bool Equals(GasOverlayData other)
115125
}
116126
}
117127

128+
if (!MathHelper.CloseToPercent(Temperature, other.Temperature))
129+
return false;
130+
118131
return true;
119132
}
120133
}
121134

135+
/// <summary>
136+
/// Calculate the heat distortion from a temperature.
137+
/// Returns 0.0f below TempAtMinHeatDistortion and 1.0f above TempAtMaxHeatDistortion.
138+
/// </summary>
139+
/// <param name="temp"></param>
140+
/// <returns></returns>
141+
public static float GetHeatDistortionStrength(float temp)
142+
{
143+
return MathHelper.Clamp01(temp * HeatDistortionSlope + HeatDistortionIntercept);
144+
}
145+
122146
[Serializable, NetSerializable]
123147
public sealed class GasOverlayUpdateEvent : EntityEventArgs
124148
{

0 commit comments

Comments
 (0)