Skip to content

[Feature] The frame is posed for its predicted presentation time: no judder for a moving object on the headset - #73

Closed
miogds wants to merge 22 commits into
feature/xr_headset_renderingfrom
feature/presentation_time_animation
Closed

miogds wants to merge 22 commits into
feature/xr_headset_renderingfrom
feature/presentation_time_animation

Conversation

@miogds

@miogds miogds commented Oct 14, 2026

Copy link
Copy Markdown
Member

Stacked on feature/xr_headset_rendering (untoldengine#1359): six commits on top of it, linear, rebased from the try branch with no conflicts.

What was wrong

On the headset the head pose is predicted for the instant the frame will be shown, but every object was posed for "now at update time": the XR loop ran the game update before its wait for the compositor's optimal input time, on a wall-clock delta between two updates, and queried the device anchor at the drawable's presentation time afterwards. The gap between the two instants is the wait plus the encode, and it varies with the previous frame's CPU work, so a moving object stepped unevenly from frame to frame while the head did not: judder at full frame rate, which the viewer's dartboard tour showed and RealityKit did not. A skipped refresh then doubled the step, and motion stepped by the time between updates drifted off the display's timeline after it.

What a frame does now

  • The XR loop converts the compositor's predicted presentation time (predictTiming().presentationTime, through the compositor clock's epoch, which is the boot like CACurrentMediaTime()) and hands it to updateXR(presentationTime:). The game update stays before the wait, where its cost sits inside the wait anyway; the engine's delta (timeSinceLastUpdate) is the time between two presentations, not between two updates, so every delta-driven path (skeletal clips, the camera path, scripts, the physics accumulator, extensions) moves in step with the display without changing.
  • FrameTime carries the presentation time, the delta, the period, the refreshes skipped and the frame index; FrameClock makes it, learning the period and counting the refreshes a frame skipped. The game receives it through setupCallbacks(frameUpdate:handleInput:), renderer.frameTime or the currentFrameTime global; gameUpdate(deltaTime:) keeps its parameter. UpdateEvent, EngineExtensionUpdateContext, RenderGraphBuildContext and RenderPassContext gain a frameTime with a default, so every caller compiles.
  • The MTKView and AR loops pose the frame one of the view's periods ahead of the draw, or for a display link's target set in predictedPresentationTime, so the API is the same on every platform.
  • TransformTrack: keys with easing sampled at an absolute time, so a frame the display skipped lands where the timeline says and the frames after it step by one period again; applyTransformTrack poses an entity from it.
  • The engine stats report the presentation delta and the skipped refreshes; the XR loop counts the frames whose drawable's presentation time differs from the prediction (presentationTimeRevisions) and prints once how far the compositor clock's epoch is from the media time.

Tests

Seventeen new tests: the frame clock (a steady cadence, a skipped refresh counted once and the next frame back to one period, jitter that is not a skip, a stall, a nominal period, time going backwards), the track (and that a repeated frame does not double-step an absolute-time animation while delta-stepped motion drifts), and, with a real renderer, that a pose the game samples in frameUpdate is the pose for the frame's presentation time rather than the wall clock, that the update event and the extensions receive the same time, and that a two-period gap reports one skipped frame. The XR module builds for the visionOS simulator, and the viewer with its matching change (feature/viewer_demo in untoldengine/UntoldArcade) builds against this branch.

Still to do on the device

docs/proposals/PresentationTimeAnimation.md has the analysis, the alternative (the update after the wait would cost a third more CPU after the optimal input time for 6.4 ms fresher input, with no margin at 100 Hz) and the headset check: the clock-base line should show only microseconds, presentationTimeRevisions should stay near zero, and the skipped refreshes should match the fps shortfall (6 in 100, 5 in 90).

Javier Segura added 6 commits October 15, 2026 00:17
… the game receives it

On the headset the head pose is predicted for the instant the frame will be
shown, but every object was posed for "now at update time", a varying lag
behind: a moving object stepped unevenly from frame to frame and read as
judder at full frame rate. The XR loop now converts the compositor's
predicted presentation time (through the compositor clock's epoch, the boot,
like CACurrentMediaTime) and hands it to updateXR(presentationTime:); the
engine's delta is the time between two presentations, not between two
updates.

FrameTime (presentation time, delta, period, skipped refreshes, frame index)
and FrameClock, which learns the period and counts the refreshes a frame
skipped, make the frame's time. It reaches the game through
setupCallbacks(frameUpdate:handleInput:), renderer.frameTime and the
currentFrameTime global; gameUpdate(deltaTime:) keeps its parameter.
UpdateEvent, EngineExtensionUpdateContext, RenderGraphBuildContext and
RenderPassContext gain a frameTime field with a default. The MTKView and AR
loops pose the frame one of the view's periods ahead of the draw, or for the
host's display-link target set in predictedPresentationTime. The engine stats
report the presentation delta and the skipped refreshes; the XR loop counts
the frames whose drawable's presentation time differs from the prediction.
Transform keys with easing, sampled at an absolute time: a frame's pose is a
function of the frame's presentation time alone, so a frame the display
skipped lands where the timeline says and the frames after it step by one
period again. applyTransformTrack poses an entity from a track for a frame.

The tests show a repeated frame does not double-step an absolute-time
animation while delta-stepped motion drifts, and, with a real renderer, that
a pose the game samples in frameUpdate matches the frame's presentation time
rather than the wall clock, that the update event and the extensions receive
the same frame time, and that a two-period gap reports one skipped frame.
The proposal records what was seen on the headset, where the loop sampled
time, the FrameTime data and how it reaches the game without breaking
callers, why the game update stays before the wait, every engine path that
samples time, the platform side, the tests, the headset check still to do and
the change's size and risk to the foveation, temporal anti-aliasing and
back-face lighting pull requests. The XR architecture doc and the starter
tutorial mention the frame's time.
…nts the skipped refreshes

The proposal records the viewer's drafted change (uncommitted in its working
tree): the tour's pose taken at the frame's presentation time through the
frameUpdate callback, and the skipped refreshes and presentation revisions
in its diagnostics and run summary, which the headset check reads. It notes
that the viewer's project points at the xr_foveation worktree, which must be
on this branch to build.
…eaches the try branch

The viewer must build against try/xr_foveation_specular_aa until this branch
is merged into it, so its change — the tour's pose taken at the frame's
presentation time through the frameUpdate callback, and the skipped
refreshes and presentation revisions in its summary — is kept as a unified
patch beside the proposal, which applies with patch -p1 from the viewer's
directory, instead of in the viewer's tree.
…uest, and the viewer carries its change

The proposal's status and its landing note follow the rebase onto
feature/xr_headset_rendering (untoldengine#1359), which rewrote
the try branch's history, and the viewer's change now applied in the
viewer's own pull request.
Javier Segura added 16 commits October 15, 2026 08:11
…the rendering deadline

A frame submitted on time whose GPU work ends after the compositor's
rendering deadline is shown a refresh late, posed for an instant already
past: a hop in a moving object that neither the loop's skipped-refresh count
nor the presentation revisions can see. The primary drawable's command
buffer now compares its GPU end time with the frame's deadline; the misses
and the longest overrun are readable (deadlineMisses,
worstDeadlineOverrunMs) and logged at most once a second.
…timal input time, and a per-second late-frame summary

On the headset nearly every frame's GPU work finished a fraction of a
millisecond after the compositor's rendering deadline, and the ones that
missed by more were the hop a moving object still showed on the tour's
sweep. setRendering(.optimalInputWait(.disabled)) is the diagnostic for the
cheapest remedy: the frame is encoded as soon as the update is done, so the
GPU starts about the wait's length earlier, and the head pose is predicted
from earlier for the compositor's reprojection to cover. The deadline
counter now reports once a second how many of that second's frames were
late, with the worst and mean overrun.
…r the compositor's deadline, and encoding before the wait halves it
…es meet the deadline, and not while they do not

setRendering(.optimalInputWait(.always | .never | .automatic)), automatic
by default: the loop keeps the wait, and the freshest head pose, while the
GPU finishes before the compositor's rendering deadline; once a second had
late frames (one in twenty or more) it encodes right after the update, so
the GPU starts about the wait's length earlier and the compositor's
reprojection covers the older prediction; it waits again once a second's
least slack would have covered the longest wait skipped, with a margin.
optimalInputWaitActive says what the last frame did.

The proposal records the adaptive wait and the design and estimate of the
true late latch (one camera buffer per frame and eye, written after the
wait, read by every pass), which is a refactor of every pass for after the
headset-rendering pull request.
…t for it instead of crashing

setShadowSoftness dereferenced the shadow system, which the renderer
creates, so a setting made with no space open — the viewer re-applies its
switches, shadow taps among them, on any change — was a crash. The setting
is now kept while no system exists, reported by getShadowSoftness as the
system will hold it, and given to the system when the renderer creates it.
…ame's GPU work does not fit one

A frame whose GPU work does not fit one refresh is shown a refresh late now
and then, which hops; shown two refreshes apart, evenly, and posed for that
instant, it moves smoothly at half the rate while the compositor reprojects
the head in between. setRendering(.framePacing(.fullRate |
.halfRateWhenLate | .halfRate)), half rate when late by default:
XRHalfRatePolicy enters when, with no wait for the optimal input time left
to skip, half the frames or more were late by over a millisecond on average
for two seconds running, and leaves when the rendered frames' GPU work has
fitted 80% of the refresh for two seconds running. While active the loop
lets every other compositor frame go, its update phase marked and nothing
submitted, and poses the rendered frames one refresh later than the
compositor's prediction while they land a refresh late. halfRatePacingActive
says the state; the per-second line adds "half rate". Eight tests cover the
policy; the proposal records the pass costs the pacing answers.
…letting a taken frame go

A compositor frame taken with queryNextFrame and not submitted is never
released, and three of them abort the app ("We reached more than 3 frames
in flight"): the first pacing, which let every other frame go, crashed as
soon as it engaged. The pacing is now in the taking: with it active, the
loop waits for the last rendered frame's GPU work to finish and then for the
next refresh after that, counted from that frame's presentation instant,
before it takes the next frame. Every frame taken is submitted, nothing
queues on the GPU, and the frames land on every other refresh while one
refresh is not enough for a frame's work.
… keeps the GPU queued

Waiting for each frame's GPU work to finish before taking the next frame
idled the GPU between frames, and it clocked down: the Forward pass went
from 9 to 12 ms, the frame no longer fitted two refreshes, and the pacing
settled at three, 30 fps. The frames are now taken on the refresh grid (the
last rendered frame's presentation instant and its multiples of the period)
every two refreshes, or every three or four when the frame's GPU work with
3 ms of encode needs them, with the next frame's work queued behind the
previous one's; the policy sets that cadence from the second's longest GPU
span, and each frame is posed for the refresh the cadence lands on. The
per-second line names the cadence.
…, and the late-frame line shows the GPU span and the pacing wait

A frame whose GPU work with its encode sat at the two-refresh boundary had
the cadence rounded up and down by turns, a second each way, and a cadence
that changes every second is itself a hop. It now rises at once and falls
only after two seconds running that fit the shorter one. The per-second
line adds the GPU span's mean and longest and the pacing wait's mean, so a
run shows where a frame's time goes.
… pose aims where the frames are measured to land

Sized from each second's longest GPU span, the cadence stayed at three
refreshes with a typical frame of 14 ms: a GPU used on half the refreshes
runs at a lower clock, and its longest spans, 19 to 21 ms with the
compositor's own work in them, never earned back the shorter cadence that
would raise the clock again. The cadence now follows the mean span plus a
fifth with the encode, and the pacing ends on the mean too. And the pose no
longer assumes a paced frame lands at the next cadence: the frames finished
9 ms past their deadline and were shown one refresh after the prediction
while the pose aimed two refreshes after it; the overrun now says the
landing refresh, which the per-second line names.
…and the cadence changes only past a deadband

Asked for two refreshes, the pacing waited three: the distance to the next
grid instant was rounded up, and with the learned period a hair short of
the compositor's, two refreshes computed as a fraction over two and became
three, every frame, 30 frames a second at a cadence meant for 45. The snap
now tolerates a quarter of a refresh. And with the typical frame right at a
cadence's boundary, the cadence rose on a slightly heavy second and fell
two seconds later, a hop every few seconds; it now changes only when the
typical frame is a millisecond past the boundary, either way.
…last frame, and the late-frame line shows the loop's split

Asked for two refreshes, the pacing still took three: its wait was aimed at
a presentation instant on the refresh grid, and a query made there landed
just after the compositor's hand-out for that refresh and blocked until the
next one, a refresh lost on every frame. The pacing now waits until the
cadence's refreshes after the compositor handed it the last frame, less
three milliseconds, and lets the query's own block align the frames to the
refreshes. The per-second line adds how long the query blocked, how long
the loop took between frames, how far after the query the presentation was
predicted and how far before it the deadline sat, so a run shows where a
paced frame's time goes.
…art, and the line reports the presentations' spacing

The compositor hands out a frame on demand (the query blocked 0.0 ms) and
predicts its presentation about three refreshes after the query. A wait
aimed at the previous frame's presentation instant therefore made every
cadence three refreshes; a wait of a cadence less a three-millisecond lead
spaced the frames 16.6 ms apart at a cadence of 20, shown two refreshes
apart, then one. The queries now fall on the pacing's own grid, the instant
it started from, every cadence exactly, a query up to a quarter refresh late
still taking its slot. The per-second line adds the mean, shortest and
longest spacing between consecutive predicted presentations, so a run reads
its evenness instead of assuming it.
…past the deadline's margin, and the pose lands by that margin

The compositor's rendering deadline sits 15 ms before the presentation on the
headset, so a frame a few milliseconds past it is still shown on time: the
full-rate runs with nearly every frame a millisecond late dropped nothing, and
the heavy run's paced frames, 3 to 6 ms past the deadline, were posed a refresh
ahead of where they were shown ("landing +1"). The policy now takes the deadline's
margin into account, less a 2 ms safety (XRHalfRatePolicy.usableMarginMs):

- The pose lands on the compositor's prediction while the frames finish within
  the usable margin, and as many refreshes later as the rest of the mean overrun
  reaches.
- The pacing enters when, with no wait left to skip, the display skipped a
  refresh for one frame in ten or more (counted in the loop from the spacing of
  consecutive predicted presentations, beyond the pacing's own cadence) or one
  frame in ten or more finished past the usable margin, for two seconds running.
  Frames merely past the deadline no longer count: the heavy run's second before
  it paced had 90 frames a millisecond past it, shown on time, and fewer hops
  than one in ten are fewer than the steps the half rate adds.

The loop counts both per second (secondMissed, secondSkipped) and prints them on
the per-second line ("N past the deadline's margin, N refreshes skipped"), which
now also appears for a second with skipped refreshes and no late frame; the GPU
completion handler receives the drawable's presentation time for the margin.

The proposal's pacing paragraph describes the final mechanism (the fixed grid,
the landing, the four lessons from the device) and records the by-eye result of
the heavy run: the even 45 fps shows fewer jumps than the hopping full rate, and
a visible stepping remains, the frame rate itself; the cure for that
configuration is the frame's GPU cost, not the timing.
@miogds

miogds commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

Closed without merging here: the frame posed for its presentation time (feature/presentation_time_animation at 27e121e) is on develop since #76 (merge 0b6348e of feature/xr_shader_cost at eac4e3e, which carries #73–#75), and develop now also holds the head of untoldengine#1359 through #78 and #79. The branch stays for the upstream pull request once untoldengine#1359 lands.

@miogds miogds closed this Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant