Rendering & GPU / Tools & Plugins / Shaders / In the Lab · 2026 · In development
Snow Deformation
A UE 5.8 plugin that simulates a persistent, world-anchored snow height field on the GPU and presses real footprints into it.
- Role
- Solo developer
- Team
- Solo
- Context
- Personal R&D
- Timeline
- 2026
- Status
- In development
Highlights
- Two compute passes per frame through the Render Dependency Graph — accumulate, then normals
- A small scrolling texture gives effectively unbounded, world-anchored tracks
- Material wiring that needs no Blueprint at all — drop in two assets and it works
- Zero GPU cost when nothing is moving and the snow has finished healing
Overview
Snow that remembers where you walked is a classic real-time effect, and most implementations cheat: decals that fade, or a render target that only covers a fixed patch of the level. I wanted the real thing as a reusable Unreal Engine plugin — a GPU height field that persists, follows the player across an arbitrarily large world, pushes up a rim of displaced snow around every print, and slowly fills back in.
It's built from scratch on the low-level path: FGlobalShader, SHADER_PARAMETER_STRUCT, FComputeShaderUtils::AddPass and ENQUEUE_RENDER_COMMAND, wrapped in a world subsystem and a lightweight actor component so gameplay code never touches the render thread.
Architecture
The game thread collects contacts; the render thread does all the work.
A world-sized surface from a 1024² texture
The height field is a fixed-resolution texture covering a square of world space centred on the player. Each frame, the accumulate pass re-samples last frame's buffer at an offset equal to how far the region moved — so tracks stay where they were made while the window scrolls. It's the same trick trail and decal systems use, applied to a persistent simulation.
Working in region-local coordinates has a second benefit: positions stay in a comfortable float range no matter how far from the world origin the level is built.
const float2 UV = (float2(Pixel) + 0.5f) / float2(TextureSize);
// Position of this texel relative to the region centre, in world units.
// Working in local space keeps everything in comfortable float range no
// matter how far from the origin the level is built.
const float2 LocalXY = (UV - 0.5f) * RegionSize;
// Where this same world position sat in last frame's buffer.
const float2 PrevUV = (LocalXY + RegionOffsetFromPrev) / RegionSize + 0.5f;
float Height = 0.0f;
if (all(PrevUV > 0.0f) && all(PrevUV < 1.0f))
{
Height = PrevHeightTexture.SampleLevel(PrevHeightSampler, PrevUV, 0).r * ClearMask;
}
// Snow slowly settles back to flat (fresh snowfall filling the tracks in).
const float Refill = RefillRate * DeltaTime;
Height = (Height > 0.0f) ? max(Height - Refill, 0.0f) : min(Height + Refill, 0.0f);Stamping footprints
Each deformer is a press with a soft core and an optional rim. The core uses max() so a deeper, more recent press always wins over an older, shallower one at the same texel — that is what makes a walked trail persist. The rim is a half-sine ring of snow shoved outwards, written only where the snow is still undisturbed so it never erases an older print.
// Stamp the deformers. max() keeps the deepest press ever made at this
// texel, which is what makes the trail persist behind the player.
for (int i = 0; i < NumDeformers; ++i)
{
const FSnowDeformerGPU D = Deformers[i];
const float Dist = length(LocalXY - D.LocalCenter);
if (Dist > D.Radius + D.RimWidth)
{
continue;
}
if (Dist <= D.Radius)
{
float Core = 1.0f - saturate(Dist / max(D.Radius, 0.001f));
Core = pow(Core, max(D.Falloff, 0.001f));
Height = max(Height, Core * D.Depth * D.Strength);
}
else if (D.RimHeight > 0.0f && Height <= 0.0f)
{
// Ring of snow shoved outwards by the footfall. Only written where
// the snow is still undisturbed so it never erases an older print.
const float R = saturate((Dist - D.Radius) / max(D.RimWidth, 0.001f));
const float Ring = sin(R * PI);
Height = min(Height, -Ring * D.RimHeight * D.Strength);
}
}
OutHeightTexture[Pixel] = Height;Normals in the same dispatch budget
A second pass turns the height field into what the material samples: R is height, GB is the world-space normal's XY, computed with central differences scaled into world units. Z is reconstructed in the material, keeping the output to a single RGBA16F texture.
[numthreads(THREADS_X, THREADS_Y, 1)]
void SnowNormalsCS(uint3 DTid : SV_DispatchThreadID)
{
const int2 Pixel = int2(DTid.xy);
if (any(Pixel >= TextureSize))
{
return;
}
const int2 MaxPixel = TextureSize - 1;
const float HL = HeightTexture.Load(int3(clamp(Pixel.x - 1, 0, MaxPixel.x), Pixel.y, 0));
const float HR = HeightTexture.Load(int3(clamp(Pixel.x + 1, 0, MaxPixel.x), Pixel.y, 0));
const float HU = HeightTexture.Load(int3(Pixel.x, clamp(Pixel.y - 1, 0, MaxPixel.y), 0));
const float HD = HeightTexture.Load(int3(Pixel.x, clamp(Pixel.y + 1, 0, MaxPixel.y), 0));
const float H = HeightTexture.Load(int3(Pixel, 0));
// Surface Z is -Height * MaxDepth, so the slope is the negated gradient.
const float2 TexelWorldSize = RegionSize / float2(TextureSize);
const float DzDx = -(HR - HL) * MaxDepth / (2.0f * TexelWorldSize.x);
const float DzDy = -(HD - HU) * MaxDepth / (2.0f * TexelWorldSize.y);
const float3 Normal = normalize(float3(-DzDx * NormalStrength, -DzDy * NormalStrength, 1.0f));
OutSnowDataTexture[Pixel] = float4(H, Normal.x, Normal.y, 1.0f);
}Challenges
Getting a runtime texture into a material
A material can't reference a texture that only exists at runtime — that turned out to be the hardest part of the whole plugin, not the shader. The final design offers three routes in order of effort: assign a render-target asset and a Material Parameter Collection in Project Settings (no Blueprint at all), register a dynamic material instance, or read the textures yourself through Blueprint-pure getters.
Signed height needs a float format
The rim of pushed-up snow is stored as negative height. A fixed-point render target silently clips it, and every print loses its ridge. The plugin checks the format at startup, warns when it's wrong, and force-enables the UAV flag if it was forgotten.
Not paying for idle snow
The dispatch is skipped entirely when the region is stationary, nothing is pressing, and the field has finished healing. A moving region always dispatches — the texture is world-anchored, so a move must be reprojected or every track would slide with the player.
Results
The plugin ships with seven documentation chapters — architecture, the GPU pipeline, a line-by-line shader walkthrough, a C++ reference, material wiring, a build-it-yourself guide and a tuning/troubleshooting reference — so it doubles as a teaching resource for Unreal's render-graph compute path.
Known limits are deliberate: a single region per world, client-side only (snow is cosmetic, so each client traces its own pawn), and a straight-down ground trace. Its companion, Sand Deformation, keeps the scrolling-region technique and replaces everything else with real granular physics.
Repository
UE5 compute-shader snow deformation plugin: persistent GPU height field with footstep-driven footprints.
- C++98.6%
- C#1.4%
- Last push
- Sep 2026