Repository navigation
Description
Problem
With data-motion-blur="on" on the composition root, a mover that sits inside another mover never blurs, even on frames where the outer one is standing still.
discoverMovers (packages/core/src/runtime/captureMotionBlur/discover.ts) makes one scan of the whole timeline: a candidate is any element that reaches MIN_SPEED (2 px per frame) at any step, and then only the outermost of nested candidates is kept:
const movers = candidates.filter((el) => !candidates.some((o) => o !== el && o.contains(el)));So an element that moves once, anywhere in the composition, owns the blur for everything inside it for the whole composition.
The common shape this hits: a card rises in over the first half second, then holds for several seconds while something inside it moves (a list that scrolls, a feed that swipes, a counter chip that pops). The card is a candidate because of its entrance, so its children are dropped, and the swipe renders sharp on both tiers (filter and capture). Remove the card's entrance and the same swipe blurs correctly.
Measured on one such composition (a 392 px swipe over 0.3 s inside a card that rose in two seconds earlier), as the summed absolute error against an 8x supersampled render averaged over a 720° shutter, relative to the unblurred render: the engine's blur alone leaves 77 % of the error; with the inner mover blurred by other means it is 39 %.
data-motion-blur="off" does not help in the other direction: optedOut walks up the ancestors, so opting the card out opts out its whole subtree and the blur cannot be handed to the child.
Proposed solution
Any one of these would cover it. In order of how little they change:
- A narrower opt-out.
data-motion-blur="off"keeps its meaning (the subtree), and a second value, saydata-motion-blur="self", removes only that element from the candidates. The author marks the card, and its children become the outermost movers. - An explicit opt-in below the root.
data-motion-blur="on"on an element makes it a mover in its own right, whatever its ancestors do; an ancestor candidate that contains an opted-in element is dropped (or kept only when it is opted in too). - Decide "outermost" per frame instead of once per composition: on a frame where the outer candidate's own speed is under
MIN_SPEED, the fastest candidate inside it is the mover for that frame.
Alternatives considered
- Restructuring the page so the inner mover is not a descendant of the card. It usually has to be one: the card's
overflow: hiddenis what clips the swipe. - Putting the card's entrance on a wrapper. The wrapper is then the candidate and still contains the child.
- What we run today for these elements: a DOM-copy shutter (sixteen additive copies along the element's sampled trajectory) on the nested movers only, each marked
data-motion-blur="off"so the engine leaves it alone, with the engine's blur on the root for everything else. It works and the two never overlap, but it costs sixteen clones per element, cannot blur a see-through fill accurately in an 8-bit compositor, and duplicates what the engine's filter tier already does better.
Additional context
Checked against 0.9.42: the filter above is unchanged. Related: #4833 asks for the engine's sub-frame blur on the CLI, which would also cover this case at render cost. The root-level blur is otherwise excellent: on text that rises it measured 35 % of the unblurred error against the same kind of reference.
Activity