---
title: "The shader moves nothing. It paints what moved"
locale: "en"
url: "https://irz.fr/en/articles/shading-motion-trace-en"
markdown_url: "https://irz.fr/en/articles/shading-motion-trace-en.md"
category: "creative"
tags: ["WebGPU", "shader", "motion", "creative coding", "GPU"]
published_at: "2026-08-20T12:45:00.000Z"
author: "Léa Perrin"
translation: "https://irz.fr/fr/articles/shading-motion-trace-fr.md"
---

# The shader moves nothing. It paints what moved

Maxime Heckel does not move objects in a shader: he turns differences between frames into masks, flow and velocity, then draws with those traces. The cost follows the pixels being processed.

The working title for this story said “draw motion in a shader instead of moving an object”. After reading Maxime Heckel’s tutorial, it is more useful to reverse that premise.

**The objects already move, and the shader tries to find the trace that movement left behind rather than becoming the thing that moves them.**

In *Shading Motion*, published on August 18, 2026, Heckel starts from a less familiar question than “how do I animate this shape?”: what happens if **motion itself becomes graphic data**, alongside color or depth?[1](https://blog.maximeheckel.com/posts/shading-motion/)

His answer uses WebGPU, compute shaders, consecutive frames and a series of intermediate textures. Once motion has been encoded as numbers, it can become heat, trails, blobs, arrows or blur.

> Illustration: Two consecutive frames feed a comparison stage that produces a motion map later reinterpreted as heatmap, blobs, arrows or blur. The decisive change is not moving geometry inside a shader. It is turning a temporal phenomenon into a texture or buffer that later rendering can interpret. Credit: IRZ analysis and illustration from Shading Motion.

## Two images

The first method is almost embarrassingly simple.

For every pixel, the compute shader loads the current color, converts it to luminance, retrieves luminance stored from the previous frame, then takes the absolute difference.[1](https://blog.maximeheckel.com/posts/shading-motion/)

In rough notation:

`motion = |luma(frame N) - luma(frame N-1)|`

A `smoothstep()` then removes changes beneath a threshold. The Book of Shaders describes that function as Hermite interpolation between two edges, useful when a hard threshold needs a gradual transition.[2](https://thebookofshaders.com/05/)

The result knows nothing about wheels, faces or spheres; it only knows **where luminance changed**.

> Illustration: Maxime Heckel diagram showing current frame, previous state and the resulting motion mask. Frame differencing does not recover velocity. It first answers a smaller question: which pixels changed between two moments? Credit: [Maxime Heckel](https://blog.maximeheckel.com/posts/shading-motion/).

That makes the technique both useful and fragile, because a moving shadow, lighting variation or video noise can create a signal even when no object has moved in the way a viewer imagines; Heckel accordingly presents frame differencing as a deliberately simple way to **estimate** motion, useful for creative material rather than geometric truth.[1](https://blog.maximeheckel.com/posts/shading-motion/)

## Make it last

The raw mask flickers because a changed pixel disappears from the signal as soon as the next frame resembles the current one.

Heckel adds persistent state: the previous trail is multiplied by a `decay` value, reduced slightly and combined with newly detected motion.[1](https://blog.maximeheckel.com/posts/shading-motion/) This changes perception more than the detector itself, since the GPU has not suddenly discovered continuous movement; **it has been told to keep a visual memory of past differences**.

Low decay erases quickly, while values close to one leave long trails. The parameter becomes art direction, turning the same detector into either a dry flash or something almost calligraphic.[1](https://blog.maximeheckel.com/posts/shading-motion/)

The useful shader lesson is that convincing motion can depend less on a complete physical model than on a carefully chosen temporal function.

## Add direction

A mask says where an image changed but not where that change travelled, so Heckel adds an optical-flow-inspired approximation in which each current pixel is compared with four neighbours in the previous state, left, right, up and down, and the opposing differences become the two components of a vector.[1](https://blog.maximeheckel.com/posts/shading-motion/)

`x = rightMatch - leftMatch`

`y = downMatch - upMatch`

That vector is normalized, mixed with previous direction and stored in a texture: red for luminance, green and blue for movement components, alpha for persistence.[1](https://blog.maximeheckel.com/posts/shading-motion/)

> Illustration: Maxime Heckel diagram showing a color-coded flow texture derived from comparisons with neighbouring pixels. Neighbour comparisons add direction to the mask. Heckel deliberately calls this optical-flow-inspired rather than production-grade computer vision. Credit: [Maxime Heckel](https://blog.maximeheckel.com/posts/shading-motion/).

That qualification matters. Heckel recommends OpenCV when accuracy is the goal; here the objective is **another input for drawing**, not a scientific movement measurement.[1](https://blog.maximeheckel.com/posts/shading-motion/)

A grid of arrows can then sample the texture, decode direction, rotate local coordinates and draw an arrow through signed distance functions. The Book of Shaders describes the same underlying idea for basic shapes: a circle can be created by asking every fragment for its distance from a centre rather than moving literal circular geometry.[3](https://thebookofshaders.com/07/)

## Draw after

The tutorial’s real conceptual shift appears once a motion map exists, because later effects become largely independent from the original scene: a bright value can feed a heatmap, a persistent area can become a mask, clusters can become boxes and segments, direction can become arrows, and velocity can become blur distance.[1](https://blog.maximeheckel.com/posts/shading-motion/)

Motion has been **compressed into an intermediate representation**.

> Pipeline
> **See, then paint**
> - Compare frame N with N−1: magnitude of change.: Detect
> - Accumulate with decay: manufacture visual continuity.: Remember
> - Compare neighbours: estimate a direction.: Orient
> - Heatmap, blobs, arrows, masks, smear or motion blur.: Interpret
> The render shader works from a representation of movement, not directly from the object's intention.

The separation is highly reusable. Once a phenomenon has become a texture, downstream shaders no longer need to know how it was obtained; a webcam can be replaced by a 3D scene, or the visualization changed, without rebuilding the entire detector.

## The cost

“On the GPU” has never meant “free”. Khronos notes that fragment shaders run for fragments produced by rasterization, with additional invocations possible for such things as derivatives or multisampling.[4](https://wikis.khronos.org/opengl/Fragment_Shader) Its GLSL recommendations give the useful order of magnitude: a scene may contain thousands of vertices while a display contains millions of pixels.[5](https://wikis.khronos.org/opengl/GLSL_%3A_recommendations)

Heckel’s compute pass is even more explicit: **one invocation processes one pixel**, and every frame dispatches work across `detectionWidth × detectionHeight`.[1](https://blog.maximeheckel.com/posts/shading-motion/)

We therefore counted only texture accesses visibly present in the published code. To be sure, this is a work budget rather than a performance benchmark, because our environment exposes neither a shader profiler nor physical GPU instrumentation.

Basic frame differencing performs, at minimum, per pixel:

- one load from the current image;
- one load from previous state;
- one write of the new state.

That is **three logical texture reads/writes per position**. The flow variant adds four neighbouring reads, bringing the visible minimum to **seven**.[1](https://blog.maximeheckel.com/posts/shading-motion/)

> Illustration: IRZ table showing pixel positions and minimum texture reads and writes for frame differencing and neighbour flow at 360p, 720p, 1080p and 4K. At 1080p/60, frame differencing covers 124.4 million pixel positions per second and exposes at least 373.2 million logical texture reads/writes; the neighbour-flow variant reaches 870.9 million. These are not GPU timings. Credit: IRZ calculation from Maxime Heckel's published code.

At **1080p and 60 fps**, the pass covers 124.4 million pixel positions each second. The basic detector therefore exposes at least **373.2 million** logical reads/writes per second, while the four-neighbour flow version reaches **870.9 million**.

At 4K those counts quadruple to roughly **1.49 billion** and **3.48 billion**.

They are not measured bandwidth or instruction counts. Cache behaviour, texture formats, workgroups, helper invocations, extra render passes and GPU architecture all change real execution time. The calculation simply explains why **analysis resolution is an obvious first lever** before somebody spends an afternoon saving one multiplication.

## Twelve blobs

The tutorial also shows another useful strategy. After paying the dense pixel-by-pixel cost of making a motion mask, Heckel compresses larger movement into only **12 blob slots**.[1](https://blog.maximeheckel.com/posts/shading-motion/)

Each slot stores four 32-bit floats, centre `x/y`, size and confidence, which makes the whole buffer `12 × 4 × 4 = 192 bytes`, the same calculation shown in the article.[1](https://blog.maximeheckel.com/posts/shading-motion/)

Detection still has work to do: each slot samples a **5 × 5 region**, giving 25 candidate positions before weighting motion, distance and exclusion.[1](https://blog.maximeheckel.com/posts/shading-motion/) Once the reduction is complete, however, drawing twelve rectangles and a handful of connecting segments is a very different problem from carrying a rich field into every later graphical decision.

The transferable pattern is **extract densely, summarize compactly, then draw from the summary** whenever the effect allows it.

## Known object

By contrast, a 3D scene has an option that ordinary video does not: the application can already know which object moved and how its projected position changed.

When object transform and camera are known, its position can be projected into screen space on consecutive frames and turned directly into a velocity vector.[1](https://blog.maximeheckel.com/posts/shading-motion/) Heckel experiments with this route to build a more accurate velocity map and apply motion blur to an object even when the camera remains still.

Accuracy moves complexity elsewhere. He creates a copy of the object in an offscreen scene, keeps the positions synchronized, applies a material that encodes velocity, renders that scene into a separate target and merges the result with the other motion data.[1](https://blog.maximeheckel.com/posts/shading-motion/)

He calls the route convoluted himself and suggests limiting it to one or two meshes, reducing vertices on the copies or considering instancing.[1](https://blog.maximeheckel.com/posts/shading-motion/)

Knowing the movement removes some inference, while representation still requires its own offscreen targets, synchronization and rendering work.

## Illusion breaks

Even an accurate velocity map meets a temporal boundary because the display observes continuous motion only at discrete moments.

Heckel demonstrates the **wagon-wheel effect**: beyond a certain speed, rotation can appear to slow, stop or reverse because positions sampled on successive frames become ambiguous.[1](https://blog.maximeheckel.com/posts/shading-motion/)[8](https://www.osar.fr/notes/motionblur/)

> Illustration: Diagram of three temporal samples from a rotating sphere producing the illusion of reversed motion. A better velocity texture does not eliminate temporal aliasing. If periodic motion is sampled too sparsely, several trajectories become compatible with the same frames. Credit: [Maxime Heckel](https://blog.maximeheckel.com/posts/shading-motion/).

His experimental fix renders copies at predictable intermediate positions, which only works when the path can be evaluated analytically between frames.[1](https://blog.maximeheckel.com/posts/shading-motion/)

Again, the extra samples do not make the shader recover hidden reality; **they give it a richer model of what happened between observations**, which reduces the ambiguity created by sparse temporal sampling.

## Debug ugly

The most useful debugging technique in the tutorial is arguably the habit of making intermediate data visible before reaching for a profiler.

The mask is rendered as grayscale, flow encodes `x/y` as color, blobs expose confidence, playgrounds surface threshold and decay, and some examples compare the stylized result against the source.[1](https://blog.maximeheckel.com/posts/shading-motion/)

That is exactly how a temporal shader becomes debuggable: before examining the final aesthetic, display **the quantity you believe you are calculating**.

Spector.js can capture WebGL frames, inspect shaders, context state and draw calls,[6](https://spector.babylonjs.com/) although Heckel’s current pipeline uses WebGPU rather than WebGL. Chrome provides WebGPU-specific developer features for testing and diagnostics, including compilation controls and, on supported backends, user labels forwarded into platform debugging tools.[7](https://developer.chrome.com/docs/web-platform/webgpu/developer-features)

Neither replaces deliberately ugly debug views:

1. control or freeze temporal input;
2. display previous luminance;
3. display raw difference before thresholding;
4. display the mask after `smoothstep`;
5. display the accumulated trail without final styling;
6. encode vectors directly as RGB;
7. only then restore heatmaps, SDFs and blur.

The shader becomes much less mystical once its intermediate numbers are allowed onto the screen.

## Motion material

The real value of *Shading Motion* is therefore not “animate a shape with maths”. The Book of Shaders already explains beautifully how `sin`, `cos`, `smoothstep` and distance fields can move or reshape procedural forms.[2](https://thebookofshaders.com/05/)[3](https://thebookofshaders.com/07/)

Heckel travels in the opposite direction: **motion happens first, then becomes material for shading**.

Two frames provide a difference, temporal memory turns that difference into a trail, neighbouring samples add an approximate direction, and a known 3D scene can contribute velocity; only after those representations exist do distance functions, blends and thresholds construct the visible effect.

The code-level change is small while the shift in viewpoint is much larger: rather than only drawing objects that move, we can draw **what their passage left behind in time**.

## References

1. [Maxime Heckel, Shading Motion, August 18 2026](https://blog.maximeheckel.com/posts/shading-motion/)
2. [The Book of Shaders, Shaping Functions](https://thebookofshaders.com/05/)
3. [The Book of Shaders, Shapes and Distance Fields](https://thebookofshaders.com/07/)
4. [Khronos OpenGL Wiki, Fragment Shader](https://wikis.khronos.org/opengl/Fragment_Shader)
5. [Khronos OpenGL Wiki, GLSL recommendations](https://wikis.khronos.org/opengl/GLSL_%3A_recommendations)
6. [Spector.js, WebGL frame capture and shader inspection](https://spector.babylonjs.com/)
7. [Chrome for Developers, WebGPU developer features](https://developer.chrome.com/docs/web-platform/webgpu/developer-features)
8. [Pierre Cusa, Motion All the Way Down](https://www.osar.fr/notes/motionblur/)
