diff --git a/Content.Client/Atmos/EntitySystems/GasTileHeatBlurOverlaySystem.cs b/Content.Client/Atmos/EntitySystems/GasTileHeatBlurOverlaySystem.cs
new file mode 100644
index 00000000000..a80208620be
--- /dev/null
+++ b/Content.Client/Atmos/EntitySystems/GasTileHeatBlurOverlaySystem.cs
@@ -0,0 +1,30 @@
+using Content.Client.Atmos.Overlays;
+using JetBrains.Annotations;
+using Robust.Client.Graphics;
+
+namespace Content.Client.Atmos.EntitySystems;
+
+///
+/// System responsible for rendering heat distortion using .
+///
+[UsedImplicitly]
+public sealed class GasTileHeatBlurOverlaySystem : EntitySystem
+{
+ [Dependency] private readonly IOverlayManager _overlayMan = default!;
+
+ private GasTileHeatBlurOverlay _gasTileHeatBlurOverlay = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _gasTileHeatBlurOverlay = new GasTileHeatBlurOverlay();
+ _overlayMan.AddOverlay(_gasTileHeatBlurOverlay);
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ _overlayMan.RemoveOverlay();
+ }
+}
diff --git a/Content.Client/Atmos/Overlays/GasTileHeatBlurOverlay.cs b/Content.Client/Atmos/Overlays/GasTileHeatBlurOverlay.cs
new file mode 100644
index 00000000000..2f540b6360a
--- /dev/null
+++ b/Content.Client/Atmos/Overlays/GasTileHeatBlurOverlay.cs
@@ -0,0 +1,261 @@
+using Content.Client.Atmos.EntitySystems;
+using Content.Client.Graphics;
+using Content.Client.Resources;
+using Content.Shared.Atmos;
+using Content.Shared.Atmos.Components;
+using Content.Shared.Atmos.EntitySystems;
+using Content.Shared.CCVar;
+using Robust.Client.Graphics;
+using Robust.Client.ResourceManagement;
+using Robust.Shared.Configuration;
+using Robust.Shared.Enums;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Prototypes;
+using System.Numerics;
+using Color = Robust.Shared.Maths.Color;
+using Texture = Robust.Client.Graphics.Texture;
+
+namespace Content.Client.Atmos.Overlays;
+
+///
+/// Overlay responsible for rendering heat distortion shader.
+///
+public sealed class GasTileHeatBlurOverlay : Overlay
+{
+ public override bool RequestScreenTexture { get; set; } = true;
+ private static readonly ProtoId UnshadedShader = "unshaded";
+ private static readonly ProtoId HeatOverlayShader = "HeatBlur";
+
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ [Dependency] private readonly IMapManager _mapManager = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly IClyde _clyde = default!;
+ [Dependency] private readonly IConfigurationManager _configManager = default!;
+ [Dependency] private readonly IResourceCache _resourceCache = default!;
+
+ private readonly SharedTransformSystem _xformSys;
+ private readonly ShaderInstance _shader;
+
+ private readonly Texture _noiseTexture;
+ private readonly Texture _heatGradientTexture;
+ private List> _intersectingGrids = new();
+ private readonly OverlayResourceCache _resources = new();
+
+ // Overlay settings
+ private const float
+ ShaderSpilling = 2.5f; // for example 4f - spills shader one tile from hotspot, 2.5f - spills it half tile
+
+ private const float ShaderStrength = 0.04f; // Makes waves stronger
+ private const float ShaderScale = 1f; // Makes more waves
+ private const float ShaderSpeed = 0.4f; // Makes waves run faster
+
+ // Overlay settings for reduced motion setting
+ private const float ShaderStrengthForReducedMotion = 0.01f;
+ private const float ShaderScaleReducedMotion = 0.5f;
+ private const float ShaderSpeedReducedMotion = 0.25f;
+
+ private const int MinDistortionTemp = 300; // Distortion starts to show up at this temperature in Kelvins
+ private const int MaxDistortionTemp = 2000; // Maximum distortion strength at this temperature in Kelvins
+
+ public override OverlaySpace Space => OverlaySpace.WorldSpace;
+
+ public GasTileHeatBlurOverlay()
+ {
+ IoCManager.InjectDependencies(this);
+ _xformSys = _entManager.System();
+
+ _noiseTexture = _resourceCache.GetTexture("/Textures/Effects/HeatBlur/perlin_noise.png");
+ _heatGradientTexture = _resourceCache.GetTexture("/Textures/Effects/HeatBlur/soft_circle.png");
+
+ _shader = _proto.Index(HeatOverlayShader).InstanceUnique();
+ _configManager.OnValueChanged(CCVars.ReducedMotion, SetReducedMotion, invokeImmediately: true);
+ }
+
+ private void SetReducedMotion(bool reducedMotion)
+ {
+ _shader.SetParameter("strength_scale", reducedMotion ? ShaderStrengthForReducedMotion : ShaderStrength);
+ _shader.SetParameter("spatial_scale", reducedMotion ? ShaderScaleReducedMotion : ShaderScale);
+ _shader.SetParameter("speed_scale", reducedMotion ? ShaderSpeedReducedMotion : ShaderSpeed);
+ }
+
+ 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.HeatTarget?.Texture.Size != target.Size)
+ {
+ res.HeatTarget?.Dispose();
+ res.HeatTarget = _clyde.CreateRenderTarget(
+ target.Size,
+ new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb),
+ name: nameof(GasTileHeatBlurOverlaySystem));
+ }
+
+ if (res.HeatBlurTarget?.Texture.Size != target.Size)
+ {
+ res.HeatBlurTarget?.Dispose();
+ res.HeatBlurTarget = _clyde.CreateRenderTarget(
+ target.Size,
+ new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb),
+ name: $"{nameof(GasTileHeatBlurOverlaySystem)}-blur");
+ }
+
+ var overlayQuery = _entManager.GetEntityQuery();
+
+ args.WorldHandle.UseShader(_proto.Index(UnshadedShader).Instance());
+
+ 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 distortion after checking all visible tiles, we can bail early
+ var anyDistortion = false;
+
+ // We're rendering in the context of the heat target texture, which will encode data as to where and how strong
+ // the heat distortion will be
+ args.WorldHandle.RenderInRenderTarget(res.HeatTarget,
+ () =>
+ {
+ _intersectingGrids.Clear();
+ _mapManager.FindGridsIntersecting(mapId, worldAABB, ref _intersectingGrids);
+ foreach (var grid in _intersectingGrids)
+ {
+ if (!overlayQuery.TryGetComponent(grid.Owner, out var comp))
+ continue;
+
+ var gridEntToWorld = _xformSys.GetWorldMatrix(grid.Owner);
+ var gridEntToViewportLocal = gridEntToWorld * worldToViewportLocal;
+
+ if (!Matrix3x2.Invert(gridEntToViewportLocal, out var viewportLocalToGridEnt))
+ continue;
+
+ var uvToUi = Matrix3Helpers.CreateScale(res.HeatTarget.Size.X, -res.HeatTarget.Size.Y);
+ var uvToGridEnt = uvToUi * viewportLocalToGridEnt;
+
+ // Because we want the actual distortion to be calculated based on the grid coordinates*, we need
+ // to pass a matrix transformation to go from the viewport coordinates to grid coordinates.
+ // * (why? because otherwise the effect would shimmer like crazy as you moved around, think
+ // moving a piece of warped glass above a picture instead of placing the warped glass on the
+ // paper and moving them together)
+ _shader.SetParameter("grid_ent_from_viewport_local", uvToGridEnt);
+
+ // 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 = worldToViewportLocal.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;
+
+ // Get the distortion strength from the temperature and bail if it's not hot enough
+ var strength = GetHeatDistortionStrength(tileGas.ByteGasTemperature);
+ if (strength <= 0f)
+ continue;
+
+ anyDistortion = true;
+
+ // Encode the strength in the red channel
+ // alpha set to 1 as tile is active
+ worldHandle.DrawTextureRect(
+ _heatGradientTexture,
+ Box2.CenteredAround(tilePosition + grid.Comp.TileSizeHalfVector,
+ grid.Comp.TileSizeVector * ShaderSpilling),
+ new Color(strength, 0f, 0f));
+ }
+ }
+ }
+ },
+ // This clears the buffer to all zero first...
+ new Color(0, 0, 0, 0));
+
+ // no distortion, no need to render
+ if (!anyDistortion)
+ {
+ 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.HeatTarget is null || res.HeatBlurTarget is null)
+ return;
+
+ _shader.SetParameter("SCREEN_TEXTURE", ScreenTexture);
+ _shader.SetParameter("NOISE_TEXTURE", _noiseTexture);
+
+ args.WorldHandle.UseShader(_shader);
+ args.WorldHandle.DrawTextureRect(res.HeatTarget.Texture, args.WorldBounds);
+
+ args.WorldHandle.UseShader(null);
+ args.WorldHandle.SetTransform(Matrix3x2.Identity);
+ }
+
+ protected override void DisposeBehavior()
+ {
+ _resources.Dispose();
+
+ _configManager.UnsubValueChanged(CCVars.ReducedMotion, SetReducedMotion);
+ base.DisposeBehavior();
+ }
+
+ ///
+ /// Gets the strength of the heat distortion effect based on the temperature of the tile.
+ /// The strength is a value between 0 and 1, where 0 means no distortion and 1 means maximum distortion.
+ ///
+ /// The temperature of the tile.
+ /// The strength of the heat distortion effect.
+ ///
+ private static float GetHeatDistortionStrength(ThermalByte temp)
+ {
+ if (!temp.TryGetTemperature(out var kelvinTemp))
+ {
+ return 0f;
+ }
+
+ var strength = (kelvinTemp - MinDistortionTemp) / (MaxDistortionTemp - MinDistortionTemp);
+
+ return MathHelper.Clamp01(strength);
+ }
+
+ internal sealed class CachedResources : IDisposable
+ {
+ public IRenderTexture? HeatTarget;
+ public IRenderTexture? HeatBlurTarget;
+
+ public void Dispose()
+ {
+ HeatTarget?.Dispose();
+ HeatBlurTarget?.Dispose();
+ }
+ }
+}
diff --git a/Resources/Prototypes/Shaders/shaders.yml b/Resources/Prototypes/Shaders/shaders.yml
index 057abf0ac23..bf740ba1f67 100644
--- a/Resources/Prototypes/Shaders/shaders.yml
+++ b/Resources/Prototypes/Shaders/shaders.yml
@@ -115,3 +115,8 @@
id: Hologram
kind: source
path: "/Textures/Shaders/hologram.swsl"
+
+- type: shader
+ id: HeatBlur
+ kind: source
+ path: "/Textures/Shaders/heatBlur.swsl"
diff --git a/Resources/Textures/Effects/HeatBlur/perlin_noise.png b/Resources/Textures/Effects/HeatBlur/perlin_noise.png
new file mode 100644
index 00000000000..f1422d52a88
Binary files /dev/null and b/Resources/Textures/Effects/HeatBlur/perlin_noise.png differ
diff --git a/Resources/Textures/Effects/HeatBlur/soft_circle.png b/Resources/Textures/Effects/HeatBlur/soft_circle.png
new file mode 100644
index 00000000000..f3ef97531c9
Binary files /dev/null and b/Resources/Textures/Effects/HeatBlur/soft_circle.png differ
diff --git a/Resources/Textures/Shaders/heatBlur.swsl b/Resources/Textures/Shaders/heatBlur.swsl
new file mode 100644
index 00000000000..269a74b3f9c
--- /dev/null
+++ b/Resources/Textures/Shaders/heatBlur.swsl
@@ -0,0 +1,34 @@
+uniform sampler2D SCREEN_TEXTURE;
+uniform sampler2D NOISE_TEXTURE; // The pre-generated noise
+
+// Those 3 are overwritten at SetReducedMotion
+uniform highp float spatial_scale; // Makes more waves
+uniform highp float strength_scale; // Makes waves stronger
+uniform highp float speed_scale; // Makes waves run faster
+
+void fragment()
+{
+ // Calculate scrolling UVs for the noise
+ highp vec2 flow1 = vec2(0.0, TIME * speed_scale);
+ highp vec2 flow2 = vec2(TIME * 0.5 * 0.1, TIME * speed_scale * 1.2);
+
+ // Sample the pre-calculated noise
+ highp float noiseVal = texture2D(NOISE_TEXTURE, fract((UV * spatial_scale) - flow1)).r;
+ highp float noiseVal2 = texture2D(NOISE_TEXTURE, fract((UV * spatial_scale) - flow2 + vec2(0.43, 0.12))).r;
+
+ // Create distortion vector
+ highp vec2 distortion = vec2(
+ noiseVal - 0.5,
+ noiseVal2 - 0.5
+ );
+
+ highp float heatStrength = texture2D(TEXTURE, UV).r;
+
+ // Non-linear curve to make the heat look more "intense" in the center
+ heatStrength = clamp(-heatStrength * heatStrength + 2.0 * heatStrength, 0.0, 1.0);
+
+ // Apply Distortion to Screen
+ highp vec2 distortedUV = UV + (distortion * strength_scale * heatStrength);
+
+ COLOR = texture2D(SCREEN_TEXTURE, distortedUV);
+}
diff --git a/Tools/generate_heat_distortion_textures.py b/Tools/generate_heat_distortion_textures.py
new file mode 100644
index 00000000000..2c8c3dd1067
--- /dev/null
+++ b/Tools/generate_heat_distortion_textures.py
@@ -0,0 +1,54 @@
+#This is script that was used to generate textures for heatdistortion
+
+from pyfastnoiselite.pyfastnoiselite import FastNoiseLite, NoiseType, FractalType
+from PIL import Image
+import math
+
+def generate_noise_image(output_filename="perlin_noise.png"):
+ width = 512
+ height = 512
+
+ noise = FastNoiseLite()
+
+ noise.noise_type = NoiseType.NoiseType_Perlin
+ noise.fractal_type = FractalType.FractalType_FBm
+ noise.fractal_octaves = 4
+ noise.frequency = 0.01
+
+ image = Image.new("RGBA", (width, height))
+ pixels = image.load()
+
+ for x in range(width):
+ for y in range(height):
+ value = (noise.get_noise(x, y) + 1.0) / 2.0
+ color_val = int(value * 255)
+ color_val = max(0, min(255, color_val))
+
+ pixels[x, y] = (color_val, color_val, color_val, 255)
+ image.save(output_filename)
+ print(f"Success! Image exported to: {output_filename}")
+
+def generate_soft_circle_texture(output_filename="soft_circle.png"):
+ width = 64
+ height = 64
+
+ image = Image.new("RGBA", (width, height))
+ pixels = image.load()
+
+ center_x = width / 2.0
+ center_y = height / 2.0
+ max_dist = width / 2.0
+
+ for x in range(width):
+ for y in range(height):
+ dist = math.sqrt((x - center_x)**2 + (y - center_y)**2)
+ fade = 1.0 - max(0.0, min(1.0, dist / max_dist))
+ alpha_val = fade * fade * (3.0 - 2.0 * fade)
+ alpha_byte = int(alpha_val * 255)
+ pixels[x, y] = (255, 255, 255, alpha_byte)
+ image.save(output_filename)
+ print(f"Success! Image exported to: {output_filename}")
+
+if __name__ == "__main__":
+ generate_noise_image()
+ generate_soft_circle_texture()