Created
July 26, 2026 05:12
-
-
Save adammyhre/192275218a61156680e1186752418a1f to your computer and use it in GitHub Desktop.
Interactive Ground Fog for Unity URP
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using UnityEngine; | |
| public class FogObstacle : MonoBehaviour { | |
| [SerializeField] float radius = 1.5f; | |
| public float Radius => radius; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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)); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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