Skip to content

[Feature] The headset's frame cost, on the Swift side: the pyramid's gate, the sun, the look pass, the fences, the textures - #74

Closed
miogds wants to merge 9 commits into
feature/presentation_time_animationfrom
feature/xr_frame_cost
Closed

miogds wants to merge 9 commits into
feature/presentation_time_animationfrom
feature/xr_frame_cost

Conversation

@miogds

@miogds miogds commented Oct 15, 2026

Copy link
Copy Markdown
Member

Stacked on feature/presentation_time_animation (#73): the headset's frame cost, on the Swift side. Nine commits.

Why

With the frame posed for its presentation time and paced evenly, the viewer's dartboard still stepped on Apple Vision Pro in the heavy configuration (MSAA 4× with TAA, the occlusion cull, foveated 2048×1984 per eye): the GPU took 13 to 15 ms a frame against an 11.1 ms refresh, so the display could show every other frame at best. The per-pass timing on the device named the costs, and each cut was refuted against the code before it was taken (five readers, three verifiers per finding).

What changes

  • 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, for an object that hides nothing but itself. An entity with its children counts as one.
  • No shadow cascades for a sun at zero intensity. The viewer's sun switch leaves the light at intensity 0 with castsShadow on, and the cascades were drawn regardless: 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 default sun is the configuration's choice (UntoldRendererConfig.defaultSun): unset, a sun on every path but the headset, whose passthrough room is lit by its probe and whose immersive environment by its own light. The camera stays on every path; the headset writes the head pose into it.
  • The look pass draws into its own descriptor, colour alone: it never touched the depth it loaded and stored through the post-process descriptor.
  • The frame's colour textures without shaderWrite, which no kernel uses and which forbids the GPU's lossless compression per Apple's texture guidance; storage stays shared for the tests' CPU reads.
  • Fence waits before the fragment stage, not the vertex stage, at 23 sites: a pass's vertex stage reads nothing a previous pass's fragments wrote, and every resource is hazard-tracked, so a pass's tiler may run while the previous pass shades.
  • No transparency pass with nothing transparent to draw.
  • The per-second XR line lists every pass with their sum, and the frame's own GPU time after the frame before.

On the device

The heavy configuration, the sun off: 17 ms a frame paced to half rate before; after, 6.8 to 9 ms, full rate at 90 and at 100 Hz, no refresh skipped after the placement, the pacing never engaged. Forward 10.40 → 8.02 ms (both eyes), TAA 2.50 → 2.06, Post 0.76 → 0.45, the shadows and the transparency pass gone, the cull 0.14 → 0.01.

Tests

Render tests for the pyramid's gate (a model's parts count as one object), the shadow gate (a sun at zero draws no cascades), the look pass's descriptor, the transparency gate and the default entities, by the GPU's own pass labels where a pass is the question. docs/proposals/PresentationTimeAnimation.md records the measurements and the reviewers' findings; the XR architecture doc's comparison table has the default-scene row.

Javier Segura added 9 commits October 15, 2026 13:06
…odel

A model loaded from a file is one entity per node under the entity it was set
on, so the viewer's single dartboard put many mesh entities into the culling
set, and the pyramid's gate (minimumEntities, 2 by default) built the depth
copy and the pyramid every frame, and ran the test for an object that hides
nothing but itself: on the headset 1 to 2.5 ms of a 13 to 15 ms frame.

The culling set now counts objects: an entity with its children, the snapshot's
object(of:) being the entity's scenegraph parent or the entity itself. Both
culling paths set hzbCandidateCount to that count; the gate, the setting and the
accessor are documented in those terms. A render test puts three parts under one
entity beside a box and sees two objects: the pyramid at a minimum of two, none
at three, where four entities would have built it.
…draw

The pass made its encoder, loading and storing the lit colour and the depth of
the whole frame, before it looked for a transparent surface: with none, every
eye paid the encoder, its bindings and three light uploads to draw nothing. The
draw list is gathered first now and the pass returns when it is empty, as the
wireframe and occluder-shell passes do. A render test reads the GPU's own pass
labels: no "Transparency" with an opaque box, the pass once its material blends.
…ge, not their vertex stage

Every scene pass waited on the shared fence before its vertex stage, after the
previous pass updated it after its fragment stage: the tiler of one pass could
not run while the renderer of the previous one shaded, and the Forward pass's
binning of a model's many parts (3.5 ms of vertex work on the headset) sat
behind the shadows' and culling's fragments. A pass's vertex stage reads nothing
a previous pass's fragments wrote (meshes, quads, joint streams), and every
resource is hazard-tracked, so the wait moves to the fragment stage at all 23
sites, as the temporal resolve already had it; every update stays as it was.
…ay compress them

The lit colour (the MSAA resolve target), the environment, the composite, the
look output and the anti-aliasing output were created with shaderWrite usage,
which no kernel uses and which, per Apple's texture guidance, forbids the GPU's
lossless compression of a texture: every pass that resolved into, read or wrote
them moved uncompressed pixels. They keep their shared storage, so the tests'
CPU reads stay as they were.
The look pass drew through the post-process descriptor, which carries the
frame's depth loaded and stored for the passes that read it as an attachment;
the look pass never tests or writes depth, so each eye loaded and stored a
full-size depth texture for nothing. It has its own descriptor now, colour
alone, its pipeline built with no depth attachment; executePostProcess takes
the descriptor to draw into and sets the depth load only when there is one. The
other post-process passes keep the frame's depth. A render test checks the
descriptor and that the look output still carries the lit frame.
The viewer's sun switch leaves the directional light in place at intensity 0
with castsShadow on, and the cascades were drawn every frame regardless: two
2048x2048 depth maps of every part of the board, 0.5 to 2.9 ms a frame on the
headset with no sun. The shading multiplies the sun's shadow by its intensity,
so the cascade update now refuses a sun at zero and the shadow passes stand
down; the picture is the same. A render test dims the scene's sun to zero and
sees no "Shadows" pass, then lights it again.
…um, and the frame's own GPU time

The per-second line named only the three heaviest passes, so the device's
numbers could not be read against the frame's span: the shadows, the look, the
culling and the transparency passes were timed and never shown. A second well
past the deadline now lists every pass, heaviest first, with their sum (a label
sums both eyes); the rest of the span lies between passes. The GPU figure adds
the frame's own time after the frame before finished: a queued frame's command
buffer is taken before the previous one is done, so the raw span overstates the
frame's work.
…set starts without one

The renderer's setup made a camera and a directional light for every path, the
headset included: a sun at intensity 1 casting cascades in a passthrough room
that has its own light, until the app replaced it. The defaults are one function
now (createDefaultEntities), the camera on every path, since the headset writes
the head pose into it, and the sun by UntoldRendererConfig.defaultSun: unset,
the platform decides, a sun everywhere but the headset, where the passthrough
room is lit by its probe and the immersive environment by its own light; a
project sets it to keep a sun on the headset (for shadows its environment cannot
cast) or to start without one elsewhere. A render test covers both cases, and
the XR architecture doc's comparison table has the row.
@miogds

miogds commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

Closed without merging here: the headset's frame cost on the Swift side (feature/xr_frame_cost at f82664c) 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