Of course, this is not a full physical simulation of sand. The effect is created from a few simple mathematical relationships, carefully chosen delays and a large number of points animated directly by the GPU. To build it, I use Three.js, WebGL, BufferGeometry and custom GLSL shaders .

The most important assumption for me was preserving good image quality. At rest, the graphic should look normal and sharp. Only during the gust should it start turning into hundreds of thousands of tiny grains.

Two versions of the same image

The entire effect is based on two layers.

The first is a regular texture with the full image. The second is a particle-based representation of the same image, created with THREE.Points .

At rest, only the full texture is visible. The point cloud remains disabled. Only after the animation starts do I show the grains and at the same time begin cutting away fragments of the regular image underneath them.

Thanks to this, I do not have to build the static image out of points. If I did that, the raster and tiny gaps would already be visible before the animation even started.

So the basic structure is quite simple:

  
    full image texture
        +
particle-based image copy
        ↓
   shared animation
  

I load Three.js as a module:

  
    <script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/This email address is being protected from spambots. You need JavaScript enabled to view it..0/build/three.module.js"
  }
}
</script>
   
 

and then:

  import * as THREE from 'three'; 
 

Because the image is fetched using fetch() , I run the example over HTTP rather than directly through file:// . There is even a simple check in the code that stops the application if the file is opened without a server.

For local testing, something like this is enough:

python3 -m http.server 5173
 

Turning the image into grains

I do not want to create particles from the full resolution of every image, so I introduce a limit:

const MAX_LONG_SIDE = 900;
 

This applies only to the sand layer. The original texture can still use the image at full resolution.

So if the source graphic is 1500 × 1000 px, the particle layer will be reduced to roughly 900 × 600 px. That still gives around 540 thousand grains, but their movement will be calculated by the GPU rather than individually in JavaScript.

The image first goes onto an invisible canvas :

const canvas = document.createElement('canvas');

const ctx = canvas.getContext('2d', {
    willReadFrequently: true
});

canvas.width = width;
canvas.height = height;

ctx.drawImage(img, 0, 0, width, height);

const { data } = ctx.getImageData(
    0,
    0,
    width,
    height
);
 

Thanks to getImageData() , I have access to the RGB values of every pixel.

For all points, I prepare separate buffers:

const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
const baseSpeed = new Float32Array(count);
const accel = new Float32Array(count);
const speedY = new Float32Array(count);
const phase = new Float32Array(count);
const releaseDelay = new Float32Array(count);
Each grain therefore has not only its position and color, but also its own speed, acceleration, vertical movement component, phase and start time.

The color is taken directly from the image:

colors[i3]     = data[p] / 255;
colors[i3 + 1] = data[p + 1] / 255;
colors[i3 + 2] = data[p + 2] / 255;
 

Thanks to this, while flying, the points retain the colors of the parts of the graphic they came from.

There is also a little randomness:

baseSpeed[i] = 80 + Math.random() * 220;
accel[i]     = 350 + Math.random() * 650;
speedY[i]    = (Math.random() - 0.5) * 90;
phase[i]     = Math.random() * Math.PI * 2;
 

If every point received identical values, all the grains would fly almost perfectly parallel. It would look more like a moving mask than wind. 🌬️

The most important part: an uneven gust front

The first version of the effect used a much simpler rule. The farther a point was from the right side of the image, the later it started moving:

const fromRight =
    1 - x / Math.max(1, width - 1);
 

That worked, but the front of the gust was too even. It created an almost vertical line moving across the graphic.

That is why, in the current version, the start time also depends on the height of the point.

First I normalize the Y position:

const ny =
    y / Math.max(1, height - 1);
 

0 means the top of the image and 1 means the bottom.

Then I use this function:

function windProfile(ny) {
    const mid = Math.sin(ny * Math.PI);

    let delayScale =
        0.72 + mid * 0.55;

    delayScale -=
        smoothstep(0.55, 1, ny) * 0.12;

    const topBoost =
        (1 - ny) * 0.08;

    return {
        delayScale,
        topBoost
    };
}
 

The sine function is convenient here because it has a low value at the top, the highest value in the middle and drops again near the bottom:

  
    top         middle        bottom
 0 ---------- 1 ---------- 0
  

I use this to increase the delay in the middle part of the image.

In practice, the upper part of the front starts earlier, the middle lags behind a little and the bottom speeds up again slightly . That is what makes the biggest difference compared to the first version.

The final delay looks like this:

releaseDelay[i] =
    fromRight
    * RELEASE_SPAN
    * delayScale
    - topBoost
    + Math.random() * 0.25;
 

So we have three things working together:

fromRight determines the main wind direction, windProfile() deforms the front vertically, and a small amount of Math.random() breaks up its mathematical regularity.

It is still a very simple model, but visually it starts to resemble an uneven gust moving across a surface. 🏜️

The shader moves the grains

The generated data is passed into BufferGeometry :

const geometry = new THREE.BufferGeometry();

geometry.setAttribute(
    'position',
    new THREE.BufferAttribute(grid.positions, 3)
);

geometry.setAttribute(
    'aColor',
    new THREE.BufferAttribute(grid.colors, 3)
);

geometry.setAttribute(
    'aBaseSpeed',
    new THREE.BufferAttribute(grid.baseSpeed, 1)
);

geometry.setAttribute(
    'aAccel',
    new THREE.BufferAttribute(grid.accel, 1)
);
 

There are also aSpeedY , aPhase and aRelease .

The whole thing becomes one object:

const sand =
    new THREE.Points(
        geometry,
        sandMat
    );
 

So I am not creating hundreds of thousands of separate JavaScript objects.

The moment when a particular grain starts flying is very simple:

float flyAge =
    uBlowAge - aRelease;

if (flyAge > 0.0) {
    // the grain has been lifted
}
 

As long as uBlowAge is smaller than aRelease , the point stays in place.

Once the wind reaches it, movement begins:

pos.x =
    home.x -
    (
        aBaseSpeed * flyAge
        + 0.5
        * aAccel
        * flyAge
        * flyAge
    );
 

This is a simple acceleration model resembling:

s = v₀t + ½at²
 

So the grain does not simply move at a constant speed. Over time, it escapes from the image faster and faster.

For the Y axis, I add individual movement and a sine wave:

pos.y =
    home.y
    + aSpeedY * flyAge
    + sin(
        uTime * 8.0 + aPhase
      )
      * 18.0
      * min(flyAge, 1.0);
 

For Z, I use a cosine:

pos.z =
    home.z
    + cos(
        uTime * 6.0 + aPhase
      )
      * 12.0
      * min(flyAge, 1.0);
 

Thanks to this, the cloud does not move only across a flat surface. Some points move slightly in front of the image, while others move deeper into the scene.

All these calculations are performed in the vertex shader.

JavaScript mainly updates the time:

uniforms.uBlowAge.value = blowAge;
uniforms.uTime.value = clock.elapsedTime;
 

So I am not running a loop through several hundred thousand points every frame. This is one of the main reasons why the effect can stay smooth despite the large number of grains.

How do we remove parts of the sharp image?

The particle animation alone is not enough. The normal texture is still underneath.

If a grain flies away while the original image fragment remains in place, we would only see points moving over a static graphic.

That is why the full texture also has its own ShaderMaterial .

The most important thing was making it disappear using the same wind profile that controls the grains.

The fragment shader contains this function:

float releaseThreshold(vec2 uv) {
    float fromRight = 1.0 - uv.x;
    float ny = 1.0 - uv.y;

    float mid =
        sin(ny * 3.14159265);

    float delayScale =
        0.72 + mid * 0.55;

    delayScale -=
        smoothstep(
            0.55,
            1.0,
            ny
        ) * 0.12;

    float topBoost =
        (1.0 - ny) * 0.08;

    ...
}
 

So the shader reproduces the same height relationship that was previously used when generating releaseDelay .

There is also a simple pseudo-random noise value:

float n =
    fract(
        sin(
            dot(
                uv,
                vec2(12.9898, 78.233)
            )
        ) * 43758.5453
    );
 

I do not need a complex Perlin Noise implementation here. The goal is only to slightly roughen the boundary.

A fragment of the image is removed with:

if (
    uBlowAge >= 0.0 &&
    (uBlowAge - uLag)
        > releaseThreshold(vUv)
) {
    discard;
}
 

discard means that the fragment is not rendered.

So I am not gradually lowering its opacity. This is not a classic fade. The image is removed piece by piece by the moving front.

I also use:

uLag: {
    value: 0.04
}
 

which introduces about 40 ms of delay between the grains starting to move and the corresponding part of the texture being cut away.

It is a small difference, but it helps prevent tiny gaps. The moving grain appears first, and only a moment later does the texture underneath disappear.

Controlling the animation

Four states are enough to control the effect:

const MODE_IDLE = 0;
const MODE_BLOW = 1;
const MODE_GONE = 2;
const MODE_REFORM = 3;
 

MODE_IDLE means a normal, static image. MODE_BLOW is the active gust. MODE_GONE means that all grains have left the screen, while MODE_REFORM starts the reverse animation.

Because the middle section of the front moves more slowly, the total gust time has to include a slightly longer tail:

const BLOW_END =
    RELEASE_SPAN * 1.35
    + 0.4
    + 4.5;
 

For a final use case such as a slider, the MODE_GONE moment can be a good place to replace the image and prepare the next animation.

Rebuilding the image in the opposite direction

For testing, I also added the ability to reverse the effect.

During MODE_REFORM , the points start outside the screen:

vec3 from =
    vec3(
        uEdge
        - 40.0
        - aPhase * 30.0,
        home.y,
        0.0
    );
 

Their progress is smoothed:

float k =
    clamp(
        uReformAge * 0.55,
        0.0,
        1.0
    );

k =
    k * k
    * (3.0 - 2.0 * k);
 

and then:

pos = mix(from, home, k);
 

mix() gradually moves each point toward its original position.

Near the end, I show the sharp image layer again. Importantly, it also returns according to the uneven profile instead of suddenly appearing all at once:

if (reformAge > 1.6) {
    plate.visible = true;

    const p =
        Math.min(
            1,
            (reformAge - 1.6) / 0.6
        );

    plateUniforms.uBlowAge.value =
        (1 - p)
        * (RELEASE_SPAN * 1.4);
}
 

Thanks to this, the reverse animation remains visually connected to the gust.

Responsiveness and grain size

The effect should work with different screen proportions, so the camera does not have one fixed position.

I calculate the required distance based on the image size, camera FOV and viewport ratio:

const halfH =
    grid.worldH * 0.52;

const distH =
    halfH /
    Math.tan(
        (camera.fov * Math.PI)
        / 360
    );
 

The width is calculated in a similar way, and the camera receives the larger of the two values.

When the window size changes, these functions are called again:

 
fitCamera();
updateEdgeUniform();
updatePointSize();
 

so the scene adapts to the viewport.

The point size is also dynamic:

gl_PointSize =
    uSize
    * (
        300.0 /
        -mvPosition.z
      );
 

Thanks to this, the grains do not suddenly become microscopic or huge just because the camera distance changes.

Can this effect be made more realistic? 🌬️

Definitely.

The current version is a compromise between appearance, simplicity and performance.

It would be possible to add more complex noise, several overlapping movement frequencies, changing wind strength or larger vortices. The grain speed could also depend on vertical position, so the upper section would not only start earlier but would also be carried more strongly by the gust.

You could also use the mouse position as a wind source or change the wind direction over time.

For now, I deliberately avoid making it more complicated. With this kind of animation, it is easy to reach the point where more mathematics no longer gives a proportionally better effect.

In my case, the biggest improvement came from a much simpler change: moving away from a perfectly straight front and making the grain release time depend on the height of the image .

Summary

[DOWNLOAD CODE]

The whole effect is based on a relatively simple idea. We have a normal, sharp image and a second representation of it built from hundreds of thousands of points.

Canvas lets us read pixel colors. BufferGeometry stores the particle data. Three.js handles the scene, camera and sending the data to WebGL, while the shaders perform the actual animation.

The most important part, however, is the shared wind profile. The same model determines when the grains start moving and when the corresponding fragments of the full texture disappear.

The upper part of the front moves a little faster, the middle lags behind, the bottom speeds up again, and a small amount of randomness removes the impression of a perfectly calculated edge. 🌬️

This is not a physical simulation of a dune, and I do not try to present it as one. It is rather an attempt to use a few simple mathematical relationships to create an effect that visually resembles a gust of sand moving across the surface of an image .

And for animated transitions between graphics, this solution is more than enough for me. 🙂