Log inGet started

Motion blur and the velocity buffer

Updated October 15, 2026

Motion blur — the camera's shutter

A frame is not an instant. motionBlur holds the shutter open for part of the frame and reconstructs the path everything took in that time out of the velocity buffer the renderer already writes:

local motionBlur = require("@builtin::systems.motionBlur.motionBlur")
motionBlur.set({ shutterAngle = 180, samples = 16 })   -- 180 degrees is the film convention
motionBlur.get()                                       -- also active() / clear()

The same settings are authorable on an entity as the MotionBlur component. shutterAngle is the physical control — motion vectors span one frame, so a shutter open for half of it smears half the per-frame travel, and doubling the frame rate halves the blur on its own. maxRadius is the longest smear the filter will carry, in pixels, whatever the shutter asks for; it defaults to 32 and cannot be set past it, because 32 px is the tile size the neighbourhood search covers. A surface the velocity buffer leaves still receives the smear of whatever moved beside it: the filter reduces velocity to tiles and then to each tile's 3x3 neighbourhood, which is what carries a direction to pixels that have none of their own and lets a moving object blur past its own silhouette.

To see the blur a player sees, read the frame the screen showed: capture { source = "presented" } (frames = n for a run of consecutive frames), or renderer.presentedFrames() for the pixels with each frame's number, jitter, render scale and exposure. An offscreen capture (source = "screen", "main", "camera", ...) draws a fresh frame with no previous camera pose, so a camera's pan smears nothing in it; objects moving in front of a still camera smear in both. The capture toolbox README's "The presented frame" section has the whole surface.

The velocity buffer, and how far a moving surface reaches in it

Every temporal technique reads one buffer: the velocity buffer, which carries each pixel's screen-space travel between the previous frame and this one. Motion blur, temporal antialiasing, temporal upsampling and a denoiser's history term all sample it one texel per pixel, and capture { pass = "motion_vectors" } draws it. That capture renders a frame of its own with no previous camera pose, so its buffer holds the objects' own motion and none of the camera's; the viewport's buffer, camera motion included, is read by putting the camera holding the viewport on the motion_vectors debug pass (the camera toolbox's set with debugPass = "motion_vectors") and taking capture { source = "presented" }. That picture is a visualization; the same buffer as numbers comes from renderer.presentedFrames({ velocity = true }), below.

A texel of it comes from whichever pass drew that pixel — a material's own fragment, the splat pass, or a render feature that names @scene.motion in its outputs — and the capture is taken at the end of the scene, once the scene's passes and its afterTransparent / afterLighting feature phases have written it. So it shows the field those readers will see rather than one contributor to it; a feature that writes @scene.motion at one of the post phases (afterScenePost, afterPost, afterUI) writes it after the picture was taken.

A camera holding the channel on a render target of its own draws the buffer over that target every frame its scene renders. A frame where the scene has nothing to draw is skipped whole, so the target keeps the last field drawn into it until the scene draws again.

A texel of it describes the surface drawn on it, and nothing else. At a moving object's edge that is a frame behind the truth: the background pixels the object is about to cover still report the background's velocity, so a reprojection reads history from where nothing moved and a blur stops dead at the silhouette.

velocityDilation changes what a texel answers. Above a radius of 0 each texel takes the velocity of the closest surface within that many texels, so a nearer moving object wins over the background it is passing in front of and its velocity reaches that far out past its own edge:

local velocityDilation = require("@builtin::systems.velocityDilation.velocityDilation")
velocityDilation.set({ radius = 2 })   -- velocity reaches 2 px past a silhouette
velocityDilation.get()                 -- also active() / clear() / maxRadius()

The same setting is authorable on an entity as the VelocityDilation component. It is off until something sets a radius, and every reader picks the wider field up with no change at its own call site. The rewrite happens after the last pass that writes velocity, so it costs two passes and one screen-sized target while armed, and (2r+1)² texture loads per pixel — which is what bounds the radius. Dilation is the per-pixel half of the same idea motionBlur carries at 32-pixel tile granularity; the two compose, and dilation is what a reader with no neighbourhood search of its own gets.

Reading the velocity buffer as numbers

renderer.presentedFrames({ velocity = true }) takes the viewport's velocity buffer with each presented frame, copied at the same moment as that frame's pixels, as frame.velocity: a VelocityField holding each pixel's travel in pixels per frame, x right and y down, signed and unclamped. field.frame is the presented frame's own number, so the field lines up with the pixels read beside it, and frames = n reads a run.

local frame = renderer.presentedFrames({ velocity = true })[1]
local field = frame.velocity
field:at(field.width // 2, field.height // 2)    -- { x, y }: the middle pixel's travel
field:row(100)                                   -- the travel of every pixel along row 100
field:region({ x = 0, y = 0, w = 64, h = 64 })   -- mean, min, max, meanSpeed, maxSpeed
frame.cpu:unload()

A surface at pixel (x, y) on the frame stood at (x - travel.x, y - travel.y) on the frame before. The field is the scene's raster (field.width x field.height, the frame's rasterWidth x rasterHeight), so below a render scale of 1 its pixels are larger than the presented frame's. The travel is measured against the projection without its sub-pixel jitter, so a still scene under temporal antialiasing reads zero. The buffer stores each travel as a displacement in normalized device coordinates, y up: a stored (u, v) is (u * width / 2, -v * height / 2) pixels, the conversion motion blur and every other reader of the buffer makes, and the one the field has already applied. It is the viewport's buffer, so it carries the camera's motion along with the objects'. A frame that drew no scene (drewScene false: a loading screen) leaves the buffer as the last drawn frame wrote it, and its field reads that.

What the motion_vectors picture encodes

The motion_vectors debug channel paints the stored value v (normalized device coordinates, as above) as a picture, each channel a value from 0 to 1 that a capture writes as a code value out of 255:

ChannelValueIn pixels per frame
Rmin(1, sqrt(100 * abs(v.x)))sqrt(200 * abs(travel.x) / width)
Gmin(1, sqrt(100 * abs(v.y)))sqrt(200 * abs(travel.y) / height)
Bmin(1, sqrt(100 * length(v)))the length of v, the same way

A still pixel is black. The square root lets sub-pixel motion show; the sign is dropped, so travel left and right paint the same, as do up and down; and a channel saturates at 255 once its component of v reaches 0.01, which is width / 200 px of horizontal travel (9.6 px on a 1920-wide raster) and height / 200 px of vertical travel (5.4 px on a 1080-tall one), counted in raster pixels, so at a render scale of 0.5 it is twice as many presented pixels. A reading of 255 says only that the travel is at or past that point. No grade runs over the channel: capture { pass = "motion_vectors" } and a presented frame of a camera holding the channel both carry these values as they are. For the travel itself, read the field.

  • documentation
  • guide
  • rendering