Status: proposal with a prototype on this branch (engine side done and tested; the viewer app's change applied in the viewer's pull request, and kept as viewer-frame-time.patch beside this document; the headset check still to do). The branch stacks on feature/xr_headset_rendering (untoldengine#1359), which rewrote the try branch's history. Written 2026-10-14 from a headset session of the same day with the viewer app (UntoldArcade-viewer), the dartboard on its eased "tour" path.
- The engine poses the head for the instant the frame will be shown, and every object for "now at update time". The XR loop queries the device anchor at
drawable.frameTiming.presentationTime, but the game ran earlier on a wall-clock delta between two updates. The gap between the two instants is the wait for the compositor's optimal input time plus the encode, and it varies with the previous frame's CPU work: the object's position steps unevenly from frame to frame while the head's does not. That reads as judder at full frame rate. - The viewer makes it worse by reading the system clock inside the update. Its tour pose comes from
ProcessInfo.processInfo.systemUptimein the game update, so it carries the same varying lag; the engine never told it when the frame would be shown. - A skipped frame doubles the step either way, but delta-driven motion drifts after it. The measured 94 fps against a 100 Hz cadence and 85 fps against 90 Hz mean about one refresh in sixteen shows the previous image again. Motion that accumulates the time between updates is then off the display's timeline by the change in lag; motion sampled at the presentation time lands where the timeline says and steps by one period again on the next frame.
- The fix is one value handed down the loop: the frame's predicted presentation time, with the delta from the previous presentation and the refreshes skipped between them (
FrameTime). The compositor predicts it (frame.predictTiming().presentationTime) before the update phase, so the game update stays where it is, before the wait, and poses the frame for that instant. The engine's own delta (timeSinceLastUpdate) becomes the presentation delta, so every delta-driven path (skeletal clips, the camera path, scripts, extensions, physics accumulation) moves in step with the display without changing. - Nothing existing breaks.
gameUpdate(deltaTime:)keeps its parameter (now the presentation delta);setupCallbacks(frameUpdate:handleInput:)hands the fullFrameTime;UpdateEvent,EngineExtensionUpdateContext,RenderGraphBuildContextandRenderPassContextgain aframeTimefield with a default. The macOS, iOS and AR loops build the sameFrameTimefrom an estimate one period ahead of the draw, or from the host's display-link target when it gives one.
On the headset, the dartboard moving along the tour shows small random steps every frame and larger occasional jumps. RealityKit moving the same object along the same path judders less. The engine's stats reported 94 fps on the 100 Hz display mode and 85 fps on 90 Hz: about one refresh in sixteen repeated the previous frame.
The loop's budget at 100 Hz, from the engine stats (EngineTimingStats): loop 10.5 ms, update 1 ms, wait for the optimal input time 6.4 ms, encode 3 ms.
The XR frame, renderNewFrame() in Sources/UntoldEngineXR/UntoldEngineXR.swift:
layerRenderer.queryNextFrame(), thenframe.predictTiming(): the compositor's predictedoptimalInputTime,renderingDeadlineandpresentationTimefor this frame (frame_timing.h: "the Mach absolute time at which the layer presents the frame onscreen").frame.startUpdate(); spatial input;renderer.updateXR(useExternalStatsLifecycle: true), which isrunFrameinSources/UntoldEngine/Renderer/UntoldEngine.swift:calculateDeltaTime()settimeSinceLastUpdatefromCACurrentMediaTime()minus the previous update'sCACurrentMediaTime(); then animation, scripts, physics andgameUpdateCallback?(timeSinceLastUpdate).frame.endUpdate();LayerRenderer.Clock().wait(until: timing.optimalInputTime)— the 6.4 ms, which is whatever the period leaves after the previous frame's work.frame.startSubmission(); for each drawable, the head pose is queried atdrawable.frameTiming.presentationTime(queryDeviceAnchor(atTimestamp:)), and the eyes are drawn with it.
So the head is posed for the presentation instant and the objects for the update's wall-clock instant, 9 to 11 ms earlier and varying. The viewer app (Viewer/App/EngineSession.swift, update()) computes tourTime from ProcessInfo.processInfo.systemUptime inside that update, and its RealityKit path (Viewer/App/RealityKitSpace.swift, apply()) does the same in SceneEvents.Update. The engine's gameUpdate callback only ever received a Float delta; nothing told the game when the frame would be shown.
The three clocks involved share one base. CACurrentMediaTime(), ProcessInfo.processInfo.systemUptime and mach_absolute_time() agreed to within the time between the calls when read back to back on this machine (78 µs apart, nothing more), the engine already mixes them (ARKit anchor timestamps against systemUptime in the lighting system, CACurrentMediaTime() for the anchor query), and the compositor's LayerRenderer.Clock counts mach absolute time from its epoch. The engine used to convert a compositor instant through "now" on both clocks; the prototype converts through the epoch, as Apple's Metal immersive sample does, and prints once at start how far the two conversions are apart (XR clock: ...), which should be the microseconds between two reads.
There is no transform keyframe or tween system for entities. The keyframes in Sources/UntoldEngine/Mesh/Skeleton.swift are skeletal clips sampled by AnimationSystem, and the camera path in CameraSystem.swift moves cameras. Neither is the cause; both are delta-driven and covered by the delta change below.
Sources/UntoldEngine/Renderer/FrameTime.swift (new):
public struct FrameTime: Sendable, Equatable {
/// Seconds at which the frame is predicted to be shown (CACurrentMediaTime base).
public let presentationTime: TimeInterval
/// Seconds from the previous frame's presentation to this one's; 0 on the first frame.
public let deltaTime: TimeInterval
/// The display's period as the loop knows it (nominal, or the shortest spacing seen).
public let period: TimeInterval
/// Refreshes between the previous frame and this one that showed the previous frame again.
public let skippedFrames: Int
/// The engine's count of frames prepared, from 1.
public let frameIndex: UInt64
public var wasFrameSkipped: Bool { skippedFrames > 0 }
}FrameClock (same file) makes one per frame from the presentation times the host predicts: the delta between consecutive presentations; the period, which the host declares (an MTKView's 1 / preferredFramesPerSecond) or the clock learns as the shortest spacing between 4 ms and 42 ms seen so far (a skip never teaches it a longer period, a stall is not a period either); and skippedFrames = round(delta / period) − 1, clamped at 0. A presentation before the previous one (a clock going backwards) passes no time. A jitter of a tenth of a millisecond on the compositor's predictions is not a skip.
UntoldRenderer.setupCallbacks(gameUpdate:handleInput:)is unchanged in shape. ItsdeltaTimeis now the presentation delta (timeSinceLastUpdate); the frame's time isrenderer.frameTimeor thecurrentFrameTimeglobal while the callback runs. The global follows the engine'stimeSinceLastUpdatestyle so code components and custom systems can read it too.UntoldRenderer.setupCallbacks(frameUpdate:handleInput:)(new, also onUntoldEngineXRandUntoldEngineAR) hands theFrameTimeitself. A distinct label, not an overload ofgameUpdate:, because an existinggameUpdate: { _ in ... }would be ambiguous between aFloatand aFrameTimeclosure and stop compiling. Each form replaces the other.UpdateEvent.frameTimeforrenderer.onUpdate { event in ... }subscribers (Sources/UntoldEngine/Systems/FrameEvents.swift); the internal initializer keepsUpdateEvent(deltaTime:renderer:)working.EngineExtensionUpdateContext.frameTime(Sources/UntoldEngine/ECS/EngineExtensions.swift) forEngineExtension.update(deltaTime:context:)andRenderExtensions, built bymakeEngineExtensionUpdateContext().RenderGraphBuildContext.frameTimeandRenderPassContext.frameTime(Sources/UntoldEngine/Systems/GraphBuilder.swift), next to theirdeltaTime: Float, built bymakeRenderGraphBuildContext()andmakeRenderPassContext(...). All three contexts takeframeTime: FrameTime = .zeroas the last initializer parameter, so every existing call site compiles.EngineTimingStats.presentationDeltaMsand.skippedFrames(Sources/UntoldEngine/Profiling/EngineStatsSnapshot.swift) so the viewer's diagnostics can count skipped refreshes directly instead of inferring them from fps.UntoldRenderer.updateXR(presentationTime:useExternalStatsLifecycle:): the host loop passes the predicted presentation time;updateXR()andupdateXR(useExternalStatsLifecycle:)still compile and estimate one period ahead of now.
calculateDeltaTime(presentationTime:nominalPeriod:) in UntoldEngine.swift is the only place the engine's delta is made. It advances the clock and sets currentFrameTime, timeSinceLastUpdate (the presentation delta) and timeSinceLastUpdatePreviousTime (now the presentation time). firstUpdateCall == false resets the clock, as the tests' state reset expects.
renderNewFrame() converts timing.presentationTime once, right after predictTiming(), and passes it to updateXR(presentationTime:); the anchor query at submission converts drawable.frameTiming.presentationTime the same way. The two should be the same instant. notePresentationRevision counts the frames where the drawable's differs from the prediction by more than half a millisecond (presentationTimeRevisions, printed at most once a second): on such a frame the object was posed for a slightly different instant than the head, by exactly that difference. The count is the headset check that the prediction is stable (section 8).
Viewer/App/EngineSession.swift: xr.setupCallbacks(frameUpdate: { [weak self] frame in self?.update(frame: frame) }, handleInput: ...), update(frame:), and the tour's time from the frame: tourTime = (frame.presentationTime - object.placed).truncatingRemainder(dividingBy: Catalog.Tour.period). object.placed is already in the same clock base (systemUptime). For the headset check, the session sums frame.skippedFrames over all frames and reads xr.presentationTimeRevisions; both go into EngineDiagnostics (skippedFrames, presentationRevisions), the run summary (MeasurementRun.swift: ", N skipped refreshes, N presentation revisions") and the controls' engine line ("frames N (M skipped)"). The RealityKit path keeps SceneEvents.Update, which gives no presentation time; RealityKit poses its own frames for the compositor.
The viewer's project points at the xr_foveation worktree (project.yml, packages.UntoldEngine.path), which builds feature/xr_headset_rendering; the viewer with this change compiles once this branch is merged into that one. The change is applied in the viewer's pull request and kept as a patch here (patch -p1 < viewer-frame-time.patch from the UntoldViewer directory; three files, taken against the viewer's tree of 2026-10-14). It was compiled against this branch through a throwaway XcodeGen project outside the viewer's tree.
Two places are possible for the update that poses the objects:
A. Before the wait (today's place), sampling the predicted presentation time. The prediction is available from predictTiming() at the start of the update phase, so nothing moves. The CPU budget is untouched: at 100 Hz the update's 1 ms sits inside the 6.4 ms the loop spends waiting anyway; a slow update (a batching rebuild, a streaming tick) shortens the wait instead of threatening the deadline. The object is posed for the instant the compositor predicts, the same instant the head is posed for 6.4 ms later; whatever the prediction's error, head and object share it, which is what removes the judder.
B. After the wait, in the submission phase. The update would run 6.4 ms closer to presentation, so game logic that reads input (a pinch dragging the object) would act on input 6.4 ms fresher. The cost: the CPU work after optimalInputTime grows from the 3 ms encode to 4 ms, a third more, on every frame, and the update's variance lands directly on the rendering deadline, where it drops frames instead of shortening a wait. With the loop at 10.5 ms for a 10 ms period there is no margin for that; at 90 Hz (11.1 ms) barely.
Recommendation: A. It fixes the judder with no budget change. If fresher input matters later, the cheap form is a small "late input" hook after the wait that only reads input poses (hands at timing.trackableAnchorTime, the head) and writes transforms, not a full update after the wait.
| Path | File / function | Today | Change |
|---|---|---|---|
| The engine's delta | UntoldEngine.swift calculateDeltaTime |
wall clock between updates | presentation delta from FrameClock (done) |
| Skeletal clips | Systems/AnimationSystem.swift updateAnimationSystem(deltaTime:), AnimationComponent.currentTime += deltaTime * playbackSpeed; currentGlobalTime += deltaTime |
delta-driven | none: the delta is now the presentation delta, so a clip's time stays in phase with the display; sampling a clip at an absolute time is possible but not needed |
| Camera path | Systems/CameraSystem.swift updateCameraPath(deltaTime:), segmentT += deltaTime / segmentDuration |
delta-driven, called by the game | none; the game passes the delta it receives. An updateCameraPath(frame:) sampling segmentT from an absolute time would make a skipped frame exact rather than in phase, a small follow-up |
| Tweening | none existed | — | Animation/TransformTrack.swift (new): keys with easing sampled at an absolute time, pose(for: frame, startedAt:) and applyTransformTrack(entityId:track:startedAt:frame:) |
| Scripts | Scripting/USCScripting.swift USCSystem.shared.update(timeSinceLastUpdate) |
delta-driven | none |
| Physics | UntoldEngine.swift physicsAccumulator += timeSinceLastUpdate, fixed step |
accumulated | none; the accumulator now follows presentations |
| Particles | none in the engine; the muscle simulation's mass points (Systems/Muscle/MuscleSimulation.swift) step by a fixed substep |
— | none; a particle system added later takes FrameTime like everything else |
| Code components, custom systems | onUpdate(deltaTime:), updateCustomSystems(deltaTime:) |
delta-driven | none; currentFrameTime is readable inside them |
| Engine and render extensions | ECS/EngineExtensions.swift update(deltaTime:context:); Systems/GraphBuilder.swift makeRenderGraphBuildContext, makeRenderPassContext |
deltaTime: Float |
context.frameTime added (done) |
| Update event | Systems/FrameEvents.swift UpdateEvent |
deltaTime |
frameTime added (done) |
| Head pose | UntoldEngineXR.swift renderNewFrame, anchor query |
drawable.frameTiming.presentationTime through "now" on both clocks |
through the compositor clock's epoch, the same conversion the game's time uses (done) |
| Room light fade | UntoldEngineXR/XREnvironmentLightingSystem.swift (ProcessInfo.systemUptime) |
wall clock | optional: a slow fade, not judder; could read currentFrameTime.presentationTime for consistency |
- visionOS (
UntoldEngineXR): the compositor's prediction, as above. The period is learnt (the compositor does not state the display's rate; the loop measures 10.0 ms at 100 Hz, 11.1 ms at 90 Hz). - macOS / iOS
MTKView(UntoldRenderer.draw(in:)): MTKView's delegate gets no timestamp from the view's own display link, so the frame is posed one period (1 / view.preferredFramesPerSecond) ahead of the draw and the period is the nominal one. The delta between frames is then the spacing of the draws, as before. A host that drives the view from its own display link (CADisplayLinkon iOS,NSView.displayLink(target:selector:)on macOS 14) pauses the view's loop (isPaused = true,enableSetNeedsDisplay = false), setsrenderer.predictedPresentationTime = link.targetTimestampand callsview.draw()from the link; the frame is then posed for the link's target. A follow-up can phase-lock the estimate fromCAMetalDrawable.addPresentedHandler'spresentedTimewithout a host display link. - iOS AR (
UntoldEngineAR.draw(in:)): one of the view's periods ahead of now, passed asupdateXR(presentationTime:). The camera image is already in the past; the objects are posed for when the composite is shown.
Tests/UntoldEngineTests/FrameClockTests.swift(9): first frame, steady cadence, a skipped refresh counted once and the next frame back to one period, jitter is not a skip, a stall counts every refresh, a nominal period, time going backwards, the estimate ahead of now, reset.Tests/UntoldEngineTests/TransformTrackTests.swift(5), includingtestARepeatedFrameDoesNotDoubleStepAnAbsoluteTimeAnimation: slots 0, 1, 2, [3 missed], 4, 5 with updates that run a varying lag before each presentation. Sampled at presentation time, the positions are exactly the timeline's, one period per frame, two across the skipped slot and one again after it; stepped by the time between updates, no step is a period and the position after the skip is off the timeline.Tests/UntoldEngineRenderTests/FrameTimeLoopTests.swift(3, with a real renderer): the loop drivesupdateXR(presentationTime:)on a timeline a thousand seconds from the wall clock; the pose the game samples inframeUpdateis the pose for the frame's presentation time, the engine's delta is the presentation delta and not the microseconds between the calls, the update event and an extension receive the sameFrameTime, the legacygameUpdatedelta is the presentation delta and a two-period gap reports one skipped frame; a frame without a prediction is posed one period ahead of now.
The compositor part cannot run in swift test; section 8 is its check.
- The start-up line
XR clock: the compositor epoch's time differs from the media time by N ms: N should be the microseconds between two reads. A large constant N means the epoch assumption is wrong and the loop must go back to converting through "now" on both clocks. presentationTimeRevisions(and its log line) over a minute of the tour: near zero means the predicted and final presentation times agree and the object is posed for the head's instant; a steady count means the prediction is revised by the compositor and the game should be posed for the drawable's time instead (option B's place), or the revision applied as a per-frame correction.- The viewer's run summary: "N skipped refreshes" over the run against the fps shortfall (6 in 100, 5 in 90): they should match. "N presentation revisions" is check 2.
- The tour, with the viewer's change of section 3.4: the small per-frame steps should be gone; the occasional larger jump remains for every skipped refresh and should now be one clean double step with no drift after it.
XR clock: the compositor epoch's time differs from the media time by -0.000 ms: the clock bases agree.- Presentation revisions: none. The drawable's presentation time is the one predicted at the update.
- Skipped refreshes: none. The loop never saw a presentation slot go by without a frame.
- The small per-frame steps are gone. A hop remained on the tour's lateral sweep only (0.63 m/s peak, 0.15° per frame at 2.37 m, where a frame shown a refresh late is a 12 mm sideways hop), with every counter above at zero.
The hop is the GPU finishing after the compositor's rendering deadline, which no counter of the loop's pacing sees; deadlineMisses and its per-second line (XR frame timing: the GPU finished after the rendering deadline on N of M frames in the last second) found it:
| Run | Late frames per second | Mean overrun | Worst |
|---|---|---|---|
| Wait for the optimal input time (default), board close | 80 to 91 of 90 | 0.5 to 1.3 ms | 2.6 ms |
| The same, board far (fewer pixels) | 15 to 44 of 90 | 0.2 to 0.7 ms | 3.0 ms |
setRendering(.optimalInputWait(.disabled)): encode right after the update |
56 of 92 | 0.42 ms | 2.2 ms |
| A room probe's prefilter, either run, for about six seconds | every frame, at 71 to 78 fps | 5 to 15 ms | 21 ms |
The compositor keeps some slack past its stated deadline (the loop still gets 90 frames a second with nearly every frame a millisecond late), and the frames beyond that slack are the hops. Encoding right after the update starts the GPU about 6 ms earlier and brought the hops down to a few; it does not make the frames finish before the deadline, so the frame's GPU work is close to the whole window. The remedies, in order of what they keep: late-latching the head pose (encode before the wait, write the eye matrices after it: the early GPU start and the fresh head pose), cutting the frame's GPU cost (the temporal resolve, the render quality), and spreading a probe's prefilter over frames so a new probe does not cost six seconds at 74 fps.
setRendering(.optimalInputWait(.always | .never | .automatic)), automatic by default: the loop waits for the optimal input time while the frames meet the deadline, stops once a second had late frames (one in twenty or more), and waits again once a second's least slack before the deadline would have covered the longest wait skipped, with a 1.5 ms margin. The fresh head pose whenever the budget allows; the compositor's reprojection of an older prediction only when it does not. optimalInputWaitActive says what the last frame did; the viewer's switch maps to automatic or never and its summary notes "input-time wait skipped (automatic)".
With the GPU pass timing on, a second of late frames names its heaviest passes. The board close (the tour's first phase, mistaken at first for a probe's prefilter): Forward 8.4 to 9.9 ms, TAA 1.4 to 3.5 ms, the depth pyramid 1 to 2.5 ms, a frame of 12.5 to 13 ms at 72 to 80 fps. The board far: Forward 4.2 to 6 ms, a frame of about 10 ms, half its frames a millisecond late. One switch at a time: TAA only (no MSAA) saves 1.5 to 2 ms, the one real saving; occlusion cull off removes the pyramid, built needlessly for one object's many meshes, for half a millisecond of frame rate at most; shadow taps 4 saves nothing measurable. The far phase can meet 100 Hz with TAA only and occlusion cull off; the close phase cannot by settings: the Forward pass is the board's own shading, scaling with the pixels it covers.
setRendering(.framePacing(.fullRate | .halfRateWhenLate | .halfRate)), half rate when late by default. XRHalfRatePolicy decides from each second's measurements. It enters when, with no wait for the optimal input time left to skip, the display skipped a refresh for one frame in ten or more (from the spacing of the compositor's consecutive predicted presentations) or one frame in ten or more finished past the deadline by more than the deadline's margin, for two seconds running: the 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 fewer hops than one in ten are fewer than the steps the half rate adds. While active the loop waits, before it asks the compositor for the next frame, until the next slot of a fixed grid: the instant the pacing started from plus a whole number of cadences (two refreshes, or three or four when the frame's typical GPU work, its mean span plus a fifth and 3 ms of encode, needs them; the cadence rises at once and falls after two fitting seconds, a millisecond's deadband at the boundary). The query itself never blocks, the compositor predicts the presentation three refreshes after it, so the grid, not the previous frame's presentation, sets the spacing, and the predicted presentations come exactly a cadence apart (22.2 ms flat at 90 Hz every second of the heavy run). Each frame is posed for the compositor's prediction plus the refreshes the frames land past it: none while they finish within the deadline's margin less a 2 ms safety, else as many as the rest reaches. Nothing waits for the GPU: the next frame's work queues behind the previous one's. (Lessons from the device: a first version let every other frame go unsubmitted, and the compositor never releases a frame taken and not submitted, three of them aborting the app with "We reached more than 3 frames in flight", so the pacing is in the taking; a second waited for each frame's GPU work before taking the next, and the idle gaps let the GPU clock down, the Forward pass going from 9 to 12 ms and the cadence from two to three, 30 fps, so the GPU is kept queued; a third snapped the query to the grid of the last frame's presentation instant, which the compositor predicts three refreshes ahead, so every frame waited a refresh too many, 30 fps again; a fourth counted the cadence from the instant the last frame was handed out, which drifted with the query's own lateness and gave an uneven 16.6 ms.) It leaves when the rendered frames' GPU work has fitted 80% of the refresh for two seconds running. halfRatePacingActive says the state; the per-second line adds "half rate, every N refreshes, landing +N", the frames past the margin and the refreshes skipped, and the spacing of the presentations; the viewer's switch maps to half rate when late or full rate, with "half-rate pacing active" in its summary. By eye on the device (2026-10-15, MSAA 4× with TAA and the occlusion cull at 90 Hz, the GPU at 13 to 15 ms a frame): the even 45 fps shows fewer jumps than the hopping full rate did, and still a visible stepping: a sweep at half the refresh rate is not smooth, it is evenly unsmooth, and the frame rate itself is the limit there. The cure for that configuration is the frame's GPU cost (the Forward pass at 6 to 9 ms, the 4× resolve, the HZB built for one object), not the timing.
The heavy configuration (MSAA 4× with TAA, the occlusion cull on, foveated 2048×1984 per eye, render quality at the platform default) measured on 2026-10-15 at 90 Hz with the sun switch off: at full rate the GPU took 16.8 to 17.6 ms a frame (Forward 8.7 to 9.2, TAA 2.2 to 3.4, Shadows 1.7), and paced to every other refresh 14 to 16 ms (the Forward pass 10 to 14 at the lower clock an idle GPU falls to). The viewer's per-pass line, both eyes summed, with the board close: Forward 10.40 ms (3.46 vertex, 6.94 fragment), TAA 2.50, Shadows 1.17 (0.93 of it vertex), Post 0.76, Culling 0.14, Transparency 0.04. The budget is 11.1 ms. Five readers mapped the passes, the forward shading, the pyramid, the temporal resolve and the timing labels, and each candidate cut was refuted against the code before it was taken; the ones that held:
- The depth pyramid's gate counts objects. A model loaded from a file is one entity per node under the entity it was set on, so one dartboard was many culling entities and built the depth copy, the pyramid and the occlusion test every frame (1 to 2.5 ms) for an object that hides nothing but itself. The gate counts an entity with its children as one. Confirmed on the device: HZB left the pass list.
- No shadow cascades for a sun at zero intensity. The viewer's sun switch leaves the light at intensity 0 with
castsShadowon, and the cascades were drawn regardless, two 2048² depth maps of every part of the board: 0.5 to 2.9 ms a frame with no sun. The shading multiplies the shadow by the intensity, so the cascade update refuses a sun at zero. - The look pass without a depth attachment. It drew through the post-process descriptor, which loads and stores the frame's depth for the passes that read it; the look pass never touches depth. It has its own colour-only descriptor and pipeline: a full-size depth read and write per eye gone.
- The frame's colour textures compressible. The lit colour, the composite, the look and anti-aliasing outputs and the environment were created with
shaderWriteusage, which no kernel uses and which forbids the GPU's lossless compression of a texture; every resolve, read and write of them moved uncompressed pixels. - Fence waits before the fragment stage. Every scene pass waited on the shared fence before its vertex stage, so one pass's tiler could not run while the previous pass shaded; a pass's vertex stage reads nothing a previous pass's fragments wrote, and every resource is hazard-tracked, so the wait moves to the fragment stage at 23 sites, as the temporal resolve already had it. The Forward pass's 3.5 ms of binning can now overlap the shadows' and culling's fragments.
- No transparency pass with nothing transparent. Its encoder loaded and stored both full-size attachments to draw nothing; the GPU spends 0.04 ms on an empty pass, so this one is tidiness.
- The per-second line lists every pass with their sum, and the frame's own GPU time after the frame before, so the device's numbers read against the span.
What did not hold, and why: the half-size pyramid, its single encoder and the first eye's copy skipped matter only to multi-object scenes; the transparency pass's cost was a guess an order of magnitude too high; moving the grade into the temporal resolve changes the history's space; float to half across the forward shader is not safe without a device check.
The device run of those cuts (the sun off, 90 Hz): the frame held the full rate, 8.4 to 9.0 ms a frame, no refresh skipped after the placement, the pacing never engaged; Forward 10.40 to 8.02 ms, TAA 2.50 to 2.06, Post 0.76 to 0.45, the shadows and the transparency pass gone, the cull 0.14 to 0.01.
What remained was the Forward pass's fragment work (5.5 of its 8 ms) and the temporal resolve, whose levers are in the shaders. Every .metal file is included by UntoldEngineKernels.metal and only the prebuilt metallibs ship, so those cuts rebuild the kernel libraries; they are on feature/xr_shader_cost, stacked on the cost branch, after twelve reviewers (Metal semantics, picture equivalence, performance, three per cut) tried to refute each:
- The sun by its intensity. The cascade shadow taps and the sun's Cook-Torrance BRDF are evaluated only when the sun's intensity is not zero, in the forward shader, the two light passes and the transparency shader, on a per-frame constant, so the branch is uniform; any other intensity takes the old path in the old order of products, bit for bit. The CPU's cascade gate says the same. Reviewers' estimate: 1 to 1.5 ms with the sun off.
- Each material map sampled only when the material has it, the normal map's decode and tangent transform included; the flags are per-draw constants, so the implicit-derivative samples stay in uniform control flow, as the height-map branch already was. The reviewers put this at nothing measurable (the removed samples were on nil textures); it stays as a cleanup.
- Once per pixel instead of twice: the view vector, the environment's rotation angle in radians, and the BRDF lookup table's pair, from which both reflectances derive (
brdfIntegration); the rotation of the lookup direction is skipped at angle zero, the headset's room probe's case, with the normalize kept, since the simulator's light pass hands in a G-buffer normal as stored and the equirectangular latitude'sasinis not scale-invariant (the reviewers' one blocker); thatasinis clamped now, which removes a pre-existing chance of a NaN pixel at the poles. - The temporal resolve leaves early for a pixel with no history or at the clear depth, before the rate-map decodes, the reprojection and the velocity fetch, which is most of a passthrough frame; the foveation rates are read as texels per screen pixel over the same screen interval from both maps, a second decode of each, instead of derivatives across the quad (which forbade any early return): the ratio is exactly 1 wherever the two maps agree, zone boundaries included, and where they differ a band at most a pixel wide before a boundary weighs a little differently than the quad's span did. Reviewers' estimate: 0.2 to 0.4 ms.
The picture tests (the material shading, per-fragment lighting, glass, probe, post-effects and temporal tests, 148 in the targeted run) pass on the rebuilt library. On the viewer's side, MSAA 2× under TAA costs 1.5 to 2 ms less than 4× for edges the temporal accumulation already resolves.
The device run of the shader cuts, the sun off, the headset at 100 Hz this time: Forward 8.02 to 6.56 ms (the fragment part 5.51 to 4.15), TAA 2.06 to 1.92 (on the empty frame 1.81 to 1.47), the frame 6.8 ms against a 10 ms refresh, no late frame after the placement. With the sun on, the frame no longer fitted (10 to 12 ms at full rate, then paced to an even 51 frames a second), and the shadow-tap count made no difference (Forward 9.48 with eight taps, 9.75 with four): the Forward pass's vertex stage had risen too, 2.41 to 3.35 ms on the same geometry, which is the device's GPU clock falling as it heats through a long session, so the sun's real cost is about 1.5 ms, 0.85 of it the two cascades and the rest the taps' setup and the sun's BRDF, as its arithmetic says.
The cheaper sun. The headset starts without a sun (UntoldRendererConfig.defaultSun), and a project that switches one on gets it in one cascade: setRendering(.shadowCascades(1 or 2)), the headset's default one and the desktop's two. A room or a tabletop never shows the distance the second cascade covers, and with maxShadowCastingDistance set to the scene's size one cascade is sharp on it; the second cascade's draw of every caster and the cross-fade's double taps go. The cascade count flows from the setting through the splits, the fitting, the passes and the shader's uniforms; the depth array keeps its slices.
The head pose is consumed on the CPU while the frame is encoded: the view and projection matrices are read at 41 places in RenderPasses.swift (each pass copies them into its own uniforms: the batches, the grid, the sky, the environment, the shadow cascades, the SSAO's projection scale), 7 in CullingSystem.swift (the frustum planes and the view-projection for the occlusion test), 5 in TemporalAntiAliasing.swift (the jitter and the reprojection), 3 in GraphBuilder.swift, and a few in the extension surface and the muscle debug pass. Encoding before the wait and writing the pose after it therefore needs one per-frame, per-eye camera buffer (view, projection, their inverses, the view-projection, the eye position, the jitter) bound at a fixed index for every pass and read by every shader in place of the copies; the frustum planes built on the GPU from it (a small kernel before the culls, or the culls reading the matrices directly); the temporal resolve taking the previous frame's buffer as its reprojection; the shadow cascades fitted from a provisional pose a few milliseconds older, which is harmless; and the drawable, with its foveation rate maps, taken before the wait, earlier than Apple's sample does, to be verified on the device first. It touches every pass and most shaders, a few days of work, and it should follow #1359 rather than ride on it. It buys about 3 ms of GPU window (the encode after the wait) with today's head pose; the adaptive wait buys about 9 ms with an older one when needed.
Size: about 330 lines in Sources (FrameTime.swift 130, TransformTrack.swift 150, the loop and context wiring 50), 330 lines of tests, this document. The viewer's change is a few lines.
The branch was cut from try/xr_foveation_specular_aa and then rebased onto feature/xr_headset_rendering (untoldengine#1359, which supersedes the three drafts below), with no conflicts; it sits on top of foveation (feature/xr_foveation, untoldengine#1342), temporal anti-aliasing (feature/taa, untoldengine#1343) and the back-face lighting fix (bugfix/backface_lighting, untoldengine#1344). Landing order: #1359, then this change on top of it.
- Foveation (#1342): textual overlap in
UntoldEngineXR.swift. The PR rewrote the drawables loop (for drawable in drawables, the rate maps); this change adds two lines inside it (the once-per-frame revision check) and five properties next to the foveation ones. Applied after the PR: clean. Applied ondevelopwithout it: the loop does not exist in that form, so the hunk must be redone by hand (two lines).GraphBuilder.swiftis touched by both, in different functions (applyXRFoveationViewportagainst the three context builders), ~15 lines apart: at worst an adjacent-hunk conflict. - Temporal anti-aliasing (#1343): one certain conflict, three lines:
updateXRinUntoldEngine.swift, where the PR addedObjectMotion.shared.beginFrame()and this change adds thepresentationTimeparameter; the resolution keeps both. Semantically the change helps TAA:ObjectMotionrecords each entity's previous transform per frame, and an object posed for the presentation instant moves by whole periods, so its motion vectors are steady instead of carrying the update's lag jitter. - Back-face lighting (#1344): shaders only, no overlap.
Behaviour changes to note in review: timeSinceLastUpdate is the presentation delta (exactly a period or a multiple on the headset; the draw spacing on a Mac as before); timeSinceLastUpdatePreviousTime holds the presentation time; EngineTimingStats.frameTotalMs on the headset is the presentation delta.
- The headset check (section 8), with the viewer's change of section 3.4.
- A
TransformTrackComponentwith a system that samples it atcurrentFrameTime.presentationTimeevery frame, so a track plays with no game code;TransformTrackis the value type it would hold. updateCameraPath(frame:)sampling from an absolute time.- A presented-time feedback for the
MTKViewestimate (section 6). - A late-input hook after the wait (section 4), or its stronger form from the headset results: late-latching the head pose, so the frame is encoded before the wait and only the eye matrices are written after it.
- The room probe's prefilter spread over frames (section 8).