Skip to content
All work

Rendering & GPU / Tools & Plugins / Math / Shaders / In the Lab · 2026 · In development

Sand Deformation

GPU sand for UE 5.8 that slumps at its angle of repose, rings with wave-equation ripples on impact and throws grains when you land.

Role
Solo developer
Team
Solo
Context
Personal R&D
Timeline
2026
Status
In development
Unreal Engine · C++ · HLSL · Compute Shaders

Highlights

  • Mass-conserving slump solver that collapses slopes steeper than ~34°, one Jacobi step per frame
  • 2D wave equation for ripples, with the speed clamped to the CFL stability limit in-shader
  • Absorbing "sponge" boundary so rings don't reflect back off the edge of the simulated region
  • Landing and jump detection that digs craters, piles rims and fires Niagara grain bursts
default angle of repose
34°
wave sub-steps per frame
4×
height, ripple, velocity, disturbance
RGBA32F
compute passes per frame
2

Overview

Sand Deformation started as a companion to Snow Deformation and turned into a different problem entirely. Snow holds whatever shape you press into it. Sand doesn't: pile it too steeply and it collapses, hit it and a ring spreads outwards, land in it and grains fly. The plugin keeps the world-anchored scrolling-region technique from the snow system and replaces all of the physics.

What makes sand different

SnowSand
SlopesHolds any shapeCollapses past its angle of repose
WavesNoneRipples propagate from impacts
RimOnly written on undisturbed snowWritten freely — slumping keeps it physical
HeightNormalised, positive = pressed downSigned world units, positive = piled up
IdleFree once nothing touches itKeeps simulating while ripples ring out

The height convention flip matters: "material flows downhill" is only simple to write when up is positive, and the slump solver compares real slopes against a real angle, so the numbers have to be world units.

Simulation state

Everything lives in one RGBA32F texture, ping-ponged between frames: R is the persistent height, G the transient ripple height, B the ripple velocity and A a decaying disturbance mask that drives material effects. A second pass combines static and ripple height into normals for the material.

PASS 1 · SANDSIMULATECSPASS 2 · SANDNORMALSCSReprojectlast frame → regionSlumpangle of reposeRipplewave equation ×4Stampcraters · rims · kicksstatic + ripple→ normalsSandDatarender targetSand material
One frame of sand — two compute passes

Slumping at the angle of repose

Dry sand can't hold a slope steeper than about 34°. For each neighbour, any height difference beyond what that angle allows is moved downhill. The update is exactly conservative: for a pair of texels the differences are equal and opposite, so whatever one gives away the other receives on its own thread — no sand is created or destroyed, and no atomics are needed.

HLSLSandDeformation.usfSource
// --- Slumping: the angle of repose ------------------------------------
//
// Dry sand cannot hold a slope steeper than ~34 degrees; pile it higher
// and the face collapses. So for each neighbour, any height difference
// beyond what that angle allows is moved downhill.
//
// This is exactly conservative: for a pair (A,B) the difference A-B and
// B-A are equal and opposite, so whatever A gives away, B receives on its
// own thread. No sand is created or destroyed.
{
	const float MaxDiff = TanAngleOfRepose * Dx;
	const float Rate = saturate(SlumpRate * Dt) * 0.25f;   // 0.25 = one quarter per neighbour

	float Flow = 0.0f;
	float4 Neighbours = float4(StateL.r, StateR.r, StateU.r, StateD.r);

	[unroll]
	for (int n = 0; n < 4; ++n)
	{
		const float Diff = Height - Neighbours[n];
		if (Diff > MaxDiff)
		{
			Flow -= (Diff - MaxDiff) * Rate;    // this texel is too high: shed sand
		}
		else if (Diff < -MaxDiff)
		{
			Flow += (-Diff - MaxDiff) * Rate;   // neighbour is too high: receive it
		}
	}

	Height += Flow;
}

It's a single Jacobi iteration per frame, so a steep pile converges over a few frames rather than instantly. That reads as sand settling — a case where the cheap solver is also the better-looking one.

Ripples: the wave equation, kept stable

Ripples integrate ∂²h/∂t² = c²∇²h as a velocity field using the standard 5-point Laplacian. An explicit solver like this is only stable while c·Δt/Δx stays below ~0.7 in 2D; past that it diverges within a few frames. Rather than trust whoever typed in the speed, the shader clamps it to what the current texel size and frame time can carry.

HLSLSandDeformation.usfSource
// --- Ripples: a 2D wave equation --------------------------------------
//
//   d2h/dt2 = c^2 * laplacian(h)
//
// integrated as velocity so it only needs one previous state rather than
// two. The laplacian is the standard 5-point stencil.
{
	const float Laplacian =
		(StateL.g + StateR.g + StateU.g + StateD.g - 4.0f * RippleHeight) / (Dx * Dx);

	// CFL condition. An explicit wave solver is only stable while
	// c * dt / dx stays below ~0.7 in 2D; past that it diverges within a
	// few frames. Rather than trust whoever typed the speed in, clamp it
	// to whatever this texel size and frame time can actually carry.
	const float MaxStableSpeed = 0.7f * Dx / max(Dt, 1e-5f);
	const float Speed = min(RippleSpeed, MaxStableSpeed);

	RippleVel += Speed * Speed * Laplacian * Dt;

	// Absorbing edge. The stencil clamps its reads at the texture border,
	// which is a perfectly reflecting wall: a ring that reaches the edge
	// turns around and comes back through the player, and since the region
	// is centred on them it does that from every side at once. Ramping the
	// damping up steeply over the outer band soaks the wave up before it
	// arrives - the cheap stand-in for a real radiating boundary.
	const float2 EdgeTexels  = min(float2(Pixel), float2(MaxPixel - Pixel));
	const float  EdgeDist    = min(EdgeTexels.x, EdgeTexels.y);
	const float  SpongeWidth = max(float(min(TextureSize.x, TextureSize.y)) * 0.06f, 4.0f);
	const float  Sponge      = 1.0f - saturate(EdgeDist / SpongeWidth);   // 0 inside, 1 at the border
	const float  EdgeDamping = Sponge * Sponge * 12.0f;                   // per second, at the very edge

	RippleVel *= saturate(1.0f - (RippleDamping + EdgeDamping) * Dt);
	RippleHeight += RippleVel * Dt;

Challenges

Rings that came back

The Laplacian clamps its reads at the texture border, which makes the edge a perfectly reflecting wall. Because the region is centred on the player, a ring that reached the edge came back through them from every side at once. The fix is a damping "sponge" that ramps up steeply over the outer 6% of the region and soaks waves up before they arrive — a cheap stand-in for a true radiating boundary.

The ghost mound

The zero-frequency mode of the wave field has a zero Laplacian, so it feels no restoring force: any net impulse that survives leaves a uniform lift that velocity damping can never remove — a phantom mound following the player around. The impulse profile is a normalised Laplacian-of-Gaussian that injects almost no zero-frequency energy, and a gentle pull on the height itself drains whatever remains without shortening the life of real rings.

Sub-stepping

At one iteration per frame a ring dies within a metre. RippleSubSteps (default 4) is what lets ripples actually travel — and shrinking the region sharpens detail but also lowers the speed cap, a trade-off the tuning docs make explicit.

Impacts and particles

Landing binds ACharacter::LandedDelegate, with strength scaled between a minimum and maximum landing speed so stepping off a kerb does nothing and a long fall digs a real crater. Jump take-off is detected as the frame the character starts falling with upward velocity. Both dig, pile and kick the wave field, and fire OnSandImpact plus an optional Niagara system that receives User.ImpactStrength and User.IsLanding — so grains are optional, not hard-wired.

Results

Like its snow sibling, the plugin ships with a full documentation set — architecture, a derivation of the sand physics, a line-by-line shader walkthrough, a C++ reference, impacts and particles, material wiring, and tuning — written so someone can rebuild it from scratch.

Repository

UE5 compute-shader sand plugin: GPU height field that slumps at its angle of repose, propagates ripples from impacts, and throws particles on landing.

  • C++98.8%
  • C#1.2%
Last push
Sep 2026