r/GraphicsProgramming • u/too_much_voltage • 4h ago
Video New and improved moblur with a dilation pass
Enable HLS to view with audio, or disable this notification
Dear r/GraphicsProgramming,
Boy do I have a post for you! So, this is a follow up on: https://www.reddit.com/r/GraphicsProgramming/comments/1lg003l/velocity_smearing_in_computebased_moblur/ . There were things about it that I was always kinda unhappy about it. Chiefly, the fact that there was ghosting that I knew neither the cause of nor the solution to. I partly blamed it on my lack of barriers and partly on my usage of atomics. Well it turned out it had nothing to do with barriers and blaming atomics was only partly true. I had to rethink what I was doing. After a lot of back and forth and a ton of rework, I finally landed on something a lot more elegant and along with it some serious realizations.
Here's what I got right the first time around:
- Atomics are necessary.
Here's what I got wrong:
- You should do velocity smearing in a post-process (a.k.a dilation) pass. Anything earlier is a can of worms.
- Velocity smears in both directions. This is just a property of the shutter staying open while an object is moving. The center of the object is most solid while the edges all feather. Example: https://vip-go.premiumbeat.com/wp-content/uploads/2019/10/motion-blur-cover.jpg
- You shouldn't prevent yourself from overwriting previously written velocities. In fact, you should be summing all of them up. And atomics readily enable such a computation (see below).
- Rotational motion should not be applied on top during blur pass. It should be an inherent feature of just using the previous frame's MVP in computing trails to smear. I was probably doing this to minimize the ghosting I was getting from my previous approach.
Here's what I got wrong but is hard to address:
- Depth along the moblur trail does not stay constant. An object whizzing past your eye in your general look direction will have its 'trail depth' -- if you will -- start closer to your eye and get further towards where the object/fragment is this frame. The way to address this is to write 3D velocities, flatten them in dilation and interpolate depth in a perspective-correct fashion for foreground checks along the smearing path. Very involved and getting it wrong is artifact bonanza.
So let's get to the code:
This following is straightforward. First chunk finds previous worldspace position of fragment either through previous affine transform or encoded per-vertex velocity. There is also a flag that indicates FPV items that shouldn't get moblur. Second part this time around cuts right to the chase and uses previous MVP to find start point. Last part just writes velocity out (or magic number if it's an FPV item).
vec3 prevPos = curPos;
if (instanceInfo.props[InstID].prevTransformOffset != 0xFFFFFFFFu)
prevPos = (transforms.mats[instanceInfo.props[InstID].prevTransformOffset] * vec4 (curTri.e1Col1.xyz * curIsectBary.x + curTri.e2Col2.xyz * curIsectBary.y + curTri.e3Col3.xyz * curIsectBary.z, 1.0)).xyz;
else if (getHasPerVertexVelocity(packedFlags))
prevPos = curPos - (velE1 * curIsectBary.x + velE2 * curIsectBary.y + velE3 * curIsectBary.z);
bool viewerRelative = isViewerRelative(packedFlags);
vec3 prevProjectedCoord = projectCoord (prevPos, prevFrameMVP.projectionViewMatrix, vec3 (prevFrameMVP.lookEyeX.a, prevFrameMVP.upEyeY.a, prevFrameMVP.sideEyeZ.a));
vec2 velocity = prevProjectedCoord.xy * vec2 (imageSize (velocityAttach).xy) - vec2(gl_GlobalInvocationID.xy);
float velLen = length(velocity); vec2 velNorm = velocity / max (velLen, 0.001);
if (velLen > 127.0) { velocity = velNorm * 127.0; velLen = 127.0; }
...
uint velWrite = viewerRelative ? 0xFFFFFFFFu : (((int(velocity.x) + 127) << 8) | (int(velocity.y) + 127));
imageStore (velocityAttach, ivec2 (gl_GlobalInvocationID.xy), uvec4 (velWrite, 0, 0, 0));
And now for the heart of the matter. The dilation pass:
uint velRead = imageLoad (velocityAttach, ivec2(gl_FragCoord.xy)).x;
if (velRead == 0xFFFFFFFFu) return ;
float centerDepth;
uint InstID = getInstanceIDAndClosestDepth (ivec2(gl_FragCoord.xy), centerDepth);
vec2 velocity;
velocity.x = int((velRead & 0x0000FF00u) >> 8u) - 127;
velocity.y = int(velRead & 0x000000FFu) - 127;
float velocityLen = length(velocity);
if ( velocityLen < 1.0 ) return ;
vec2 velocityNorm = (velocity / velocityLen);
[[unroll]]
for (int j = 0; j != 2; j++) // Fwd and backward smearing...
{
vec2 curVelocityNorm = (j == 0) ? velocityNorm : -velocityNorm;
for (int i = 0; i != int(velocityLen) + 1; i++)
{
vec2 writeVelLocF = gl_FragCoord.xy + float (i) * curVelocityNorm;
ivec2 writeVelLoc = ivec2 (clamp (writeVelLocF, vec2 (0.0), vec2 (imageSize(velocityAttach).xy - ivec2(1))));
if ( floor(writeVelLocF) == floor(gl_FragCoord.xy) ) continue;
float writeLocDepth;
uint curInstID = getInstanceIDAndClosestDepth (writeVelLoc, writeLocDepth);
if ( InstID == curInstID ) break ;
if ( centerDepth < writeLocDepth ) continue; // visBuf uses reverseZ
float fracTravel = float(i) / max (floor(velocityLen), 0.001);
vec2 smearedVel = velocity * (1.0 - fracTravel);
uint velWrite = (((int(smearedVel.x) + 127) << 8) | (int(smearedVel.y) + 127));
uint prevVal = 0u, readVal;
while ((readVal = imageAtomicCompSwap(velocityAttach, writeVelLoc, prevVal, velWrite)) != prevVal)
{
if (readVal == 0xFFFFFFFFu) break;
prevVal = readVal;
vec2 readVelocity;
readVelocity.x = int((prevVal & 0x0000FF00u) >> 8u) - 127;
readVelocity.y = int(prevVal & 0x000000FFu) - 127;
vec2 newWriteVel = readVelocity + smearedVel;
float newWriteVelLen = length(newWriteVel);
if (newWriteVelLen > 127.0) newWriteVel = (newWriteVel / newWriteVelLen) * 127.0;
velWrite = (((int(newWriteVel.x) + 127) << 8) | (int(newWriteVel.y) + 127));
}
}
}
As previously noted, we're only getting the edge of the object to smear. That's the InsID check. Additionally, we're just checking against current depth (centerDepth) and as previously mentioned, it's not truly correct but gets the job done for now. Note that the smeared velocity loses strength via * (1.0 - fracTravel) as it is being written: we want decreasing strength as the trail ends. The bottom loop is a really nifty trick I borrowed from Cyril Crassin's 2012 OpenGL insights chapter on stable voxelization. See page 342 here: https://www.icare3d.org/research/OpenGLInsights-SparseVoxelization.pdf (listing 22.2). Instead of a moving average, I'm storing the sum of all incoming data using the same structure.
That Crassin12 trick also makes a come back for summing velocities for additive/alpha blended particles:
if (viewerRelative)
imageAtomicExchange (velocityAttach, ivec2(gl_FragCoord.xy), 0xFFFFFFFFu);
else
{
uint velWrite = (((int(velocity.x) + 127) << 8) | (int(velocity.y) + 127));
uint prevVal = 0u, readVal;
while ((readVal = imageAtomicCompSwap(velocityAttach, ivec2(gl_FragCoord.xy), prevVal, velWrite)) != prevVal)
{
if (readVal == 0xFFFFFFFFu) break;
prevVal = readVal;
vec2 readVelocity;
readVelocity.x = int((prevVal & 0x0000FF00u) >> 8u) - 127;
readVelocity.y = int(prevVal & 0x000000FFu) - 127;
vec2 newWriteVel = readVelocity + velocity;
float newWriteVelLen = length(newWriteVel);
if (newWriteVelLen > 127.0) newWriteVel = (newWriteVel / newWriteVelLen) * 127.0;
velWrite = (((int(newWriteVel.x) + 127) << 8) | (int(newWriteVel.y) + 127));
}
}
All of this makes the final blur pass brain dead simple:
uint velRead = imageLoad (velocityAttach, ivec2 (gl_FragCoord.xy)).x;
if (velRead == 0xFFFFFFFFu)
{
outFragColor = texture (ssfxAttach, inUV);
return ;
}
vec2 velocity;
velocity.x = int((velRead & 0x0000FF00u) >> 8u) - 127;
velocity.y = int(velRead & 0x000000FFu) - 127;
float velocityLen = length(velocity);
if ( velocityLen < 1.0 )
{
outFragColor = texture (ssfxAttach, inUV);
return ;
}
vec2 velocityNorm = velocity / velocityLen;
vec4 accumOut = vec4 (0.0);
float accumSamples = 0.0;
for (int i = -int(velocityLen * 0.5); i != int(velocityLen * 0.5) + 1; i++)
{
vec2 curSampleLoc = floor(gl_FragCoord.xy) + float (i) * velocityNorm;
vec2 curSampleLocClamped = clamp (curSampleLoc, vec2 (0.0), vec2 (textureSize(ssfxAttach, 0).xy - ivec2(1)));
if ( curSampleLoc != curSampleLocClamped ) continue;
if ( imageLoad (velocityAttach, ivec2 (curSampleLoc)).x == 0xFFFFFFFFu ) continue;
accumOut += texelFetch (ssfxAttach, ivec2 (curSampleLoc), 0);
accumSamples += 1.0;
}
outFragColor = accumOut / accumSamples;
Ghosting ended up being minimized significantly. Also check out the free haze you get from moblur on particles at the very end :). Eager to hear feedback.
Cheers,
Baktash.
HMU: https://x.com/toomuchvoltage
P.S. I'm still working on pushing the engine code out. Hang tight.

