Skip to content

Instantly share code, notes, and snippets.

@adammyhre
Created July 26, 2026 05:12
Show Gist options
  • Select an option

  • Save adammyhre/192275218a61156680e1186752418a1f to your computer and use it in GitHub Desktop.

Select an option

Save adammyhre/192275218a61156680e1186752418a1f to your computer and use it in GitHub Desktop.
Interactive Ground Fog for Unity URP
using UnityEngine;
public class FogObstacle : MonoBehaviour {
[SerializeField] float radius = 1.5f;
public float Radius => radius;
}
#pragma kernel CSMain
Texture2D<float> Source;
SamplerState linearClampSampler;
RWTexture2D<float> Result;
uint _Resolution;
float2 _PlayerPos; // UV space
float2 _PlayerVel; // UV per second
float _PlayerRadius; // UV space
float2 _Wind; // UV per second
float _Push, _Swirl, _Regrow;
float t, dt;
struct Obstacle {
float2 position; // UV space
float radius; // UV space
};
StructuredBuffer<Obstacle> _Obstacles;
uint _ObstacleCount;
float Hash(float2 p) {
return frac(sin(dot(p, float2(127.1, 311.7))) * 43758.5453);
}
float Noise(float2 p) {
float2 i = floor(p), f = frac(p);
f = f * f * (3.0 - 2.0 * f);
float a = Hash(i), b = Hash(i + float2(1, 0));
float c = Hash(i + float2(0, 1)), d = Hash(i + float2(1, 1));
return lerp(lerp(a, b, f.x), lerp(c, d, f.x), f.y);
}
[numthreads(8, 8, 1)]
void CSMain(uint3 id : SV_DispatchThreadID) {
if (id.x >= _Resolution || id.y >= _Resolution) return;
float2 uv = (id.xy + 0.5) / _Resolution;
// Analytic velocity field: wind + radial push away from the player + tangential swirl for the wake
float2 toTexel = uv - _PlayerPos;
float dist = length(toTexel);
float2 dir = toTexel / max(dist, 1e-5);
float influence = exp(-dist * dist / (_PlayerRadius * _PlayerRadius * 8.0));
float speed = length(_PlayerVel);
float2 velocity = _Wind
+ dir * (speed * _Push * influence)
+ float2(-dir.y, dir.x) * (speed * _Swirl * influence * sin(t * 3.0 + dist * 60.0));
// Semi-Lagrangian advection: back-trace along the velocity and sample where the fog came from
float density = Source.SampleLevel(linearClampSampler, uv - velocity * dt, 0);
// The player body itself carves a hole in the fog
density *= smoothstep(_PlayerRadius * 0.6, _PlayerRadius, dist);
// Each obstacle clears a disc; the wind then flows fog around it
for (uint i = 0; i < _ObstacleCount; i++) {
float obstacleDist = length(uv - _Obstacles[i].position);
density *= smoothstep(_Obstacles[i].radius * 0.6, _Obstacles[i].radius, obstacleDist);
}
// Slowly regrow toward a drifting two-octave noise target so the wake heals over
float n = Noise(uv * 6.0 + _Wind * t * 4.0) * 0.65 + Noise(uv * 14.0 - _Wind * t * 2.0) * 0.35;
Result[id.xy] = lerp(density, n * n * 1.4, saturate(_Regrow * dt));
}
using System.Runtime.InteropServices;
using UnityEngine;
public class FogSimulation : MonoBehaviour {
#region Fields
[SerializeField] ComputeShader compute;
[SerializeField] Material fogMaterial;
[SerializeField] Transform player;
[SerializeField] int resolution = 256;
[SerializeField] float areaSize = 30f;
[SerializeField] float playerRadius = 1.8f;
[SerializeField] Vector2 wind = new(0.02f, 0.008f);
[SerializeField, Range(0f, 10f)] float pushStrength = 4.5f;
[SerializeField, Range(0f, 10f)] float swirlStrength = 1.5f;
[SerializeField, Range(0f, 2f)] float regrowRate = 0.25f;
RenderTexture[] density;
int current, kernel, groups;
Vector3 previousPlayerPos;
public struct ObstacleData {
public Vector2 position;
public float radius;
}
ComputeBuffer obstacleBuffer;
#endregion
void Start() {
kernel = compute.FindKernel("CSMain");
groups = Mathf.CeilToInt(resolution / 8f);
density = new RenderTexture[2];
for (int i = 0; i < 2; i++) {
density[i] = new RenderTexture(resolution, resolution, 0, RenderTextureFormat.RFloat) {
enableRandomWrite = true,
wrapMode = TextureWrapMode.Clamp
};
density[i].Create();
}
previousPlayerPos = player.position;
InitializeObstacles();
// Prime the field so we start with fully grown fog instead of an empty texture
for (int i = 0; i < 90; i++) Step(0.1f);
}
void Update() => Step(Time.deltaTime);
void OnDestroy() {
obstacleBuffer?.Release();
if (density == null) return;
foreach (var rt in density) rt.Release();
}
void InitializeObstacles() {
var obstacles = FindObjectsByType<FogObstacle>(FindObjectsSortMode.None);
var data = new ObstacleData[obstacles.Length];
for (int i = 0; i < obstacles.Length; i++) {
data[i].position = WorldToUv(obstacles[i].transform.position);
data[i].radius = obstacles[i].Radius / areaSize;
}
obstacleBuffer = new ComputeBuffer(Mathf.Max(1, data.Length), Marshal.SizeOf(typeof(ObstacleData)));
obstacleBuffer.SetData(data);
compute.SetInt("_ObstacleCount", data.Length);
compute.SetBuffer(kernel, "_Obstacles", obstacleBuffer);
}
void Step(float dt) {
int next = 1 - current;
Vector2 playerUv = WorldToUv(player.position);
Vector2 playerVelUv = dt > 0f ? (playerUv - WorldToUv(previousPlayerPos)) / dt : Vector2.zero;
compute.SetInt("_Resolution", resolution);
compute.SetVector("_PlayerPos", playerUv);
compute.SetVector("_PlayerVel", playerVelUv);
compute.SetFloat("_PlayerRadius", playerRadius / areaSize);
compute.SetVector("_Wind", wind);
compute.SetFloat("_Push", pushStrength);
compute.SetFloat("_Swirl", swirlStrength);
compute.SetFloat("_Regrow", regrowRate);
compute.SetFloat("t", Time.time);
compute.SetFloat("dt", dt);
compute.SetTexture(kernel, "Source", density[current]);
compute.SetTexture(kernel, "Result", density[next]);
compute.Dispatch(kernel, groups, groups, 1);
fogMaterial.SetTexture("_DensityTex", density[next]);
current = next;
previousPlayerPos = player.position;
}
Vector2 WorldToUv(Vector3 worldPos) {
Vector3 local = worldPos - transform.position;
return new Vector2(local.x / areaSize + 0.5f, local.z / areaSize + 0.5f);
}
}
Shader "GitAmend/FogVolume" {
Properties {
_DensityTex ("Density", 2D) = "white" {}
_FogColor ("Fog Color", Color) = (0.85, 0.9, 1.0, 1.0)
_ShadowColor ("Shadow Color", Color) = (0.45, 0.5, 0.62, 1.0)
_DensityScale ("Density Scale", Range(0, 20)) = 6.0
}
SubShader {
Tags { "RenderType" = "Transparent" "Queue" = "Transparent" "RenderPipeline" = "UniversalPipeline" }
Blend One OneMinusSrcAlpha
ZWrite Off
Cull Front
Pass {
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D(_DensityTex);
SAMPLER(sampler_DensityTex);
float4 _FogColor, _ShadowColor;
float _DensityScale;
struct Attributes { float4 positionOS : POSITION; };
struct Varyings {
float4 positionCS : SV_POSITION;
float3 positionOS : TEXCOORD0;
float3 cameraOS : TEXCOORD1;
};
Varyings vert(Attributes input) {
Varyings output;
output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
output.positionOS = input.positionOS.xyz;
output.cameraOS = TransformWorldToObject(_WorldSpaceCameraPos);
return output;
}
// Slab intersection against the unit cube in object space; returns (tNear, tFar)
float2 BoxIntersect(float3 ro, float3 rd) {
float3 t0 = (-0.5 - ro) / rd;
float3 t1 = (0.5 - ro) / rd;
float3 tmin = min(t0, t1), tmax = max(t0, t1);
return float2(max(max(tmin.x, tmin.y), tmin.z), min(min(tmax.x, tmax.y), tmax.z));
}
half4 frag(Varyings input) : SV_Target {
float3 ro = input.cameraOS;
float3 rd = normalize(input.positionOS - ro);
float2 t = BoxIntersect(ro, rd);
t.x = max(t.x, 0.0); // camera may be inside the volume
const int stepCount = 16;
float stepSize = (t.y - t.x) / stepCount;
float3 p = ro + rd * (t.x + stepSize * 0.5);
float3 dp = rd * stepSize;
float3 color = 0.0;
float transmittance = 1.0;
[unroll]
for (int i = 0; i < stepCount; i++) {
float d = SAMPLE_TEXTURE2D_LOD(_DensityTex, sampler_DensityTex, p.xz + 0.5, 0).r;
d *= smoothstep(0.5, -0.3, p.y) * _DensityScale; // fog thins toward the top of the slab
float a = 1.0 - exp(-d * stepSize); // Beer-Lambert per step
color += lerp(_ShadowColor.rgb, _FogColor.rgb, saturate(p.y + 0.7)) * (a * transmittance);
transmittance *= 1.0 - a;
p += dp;
}
return half4(color, 1.0 - transmittance);
}
ENDHLSL
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment