Skip to content

[Feature] Foveated rendering in XR through the compositor's rasterization rate maps - #1342

Closed
miogds wants to merge 3 commits into
untoldengine:developfrom
miolabs:feature/xr_foveation
Closed

miogds wants to merge 3 commits into
untoldengine:developfrom
miolabs:feature/xr_foveation

Conversation

@miogds

@miogds miogds commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

On visionOS, a layer configured with isFoveationEnabled hands the engine one MTLRasterizationRateMap per eye and colour/depth textures of the map's physical size, smaller than the screen the eye sees; the GPU draws through the map (dense where the eyes look, coarse in the periphery) and the compositor unwarps the result. The engine ignored the maps, so apps had to keep foveation off — and a layer without foveation is also refused a maxRenderQuality above the platform default ("Layer configuration not supported", code -18), which leaves the per-eye drawables at roughly 1900 px, about 2 mm per pixel at 2.4 m: thin wires and digits look soft next to SwiftUI windows, which draw at panel density.

This PR makes the XR path draw through the compositor's rate maps (dedicated layout, the stereo path), on by default whenever the drawable provides them, with setRendering(.foveatedRendering(.disabled)) as a diagnostic switch.

Pass changes

  • UntoldEngineXR.executeXRSystemPass makes an XRFoveationFrame per eye (renderInfo.xrFoveation): the map, the eye's viewport in the map's screen space (view.textureMap.viewport) and the map's parameter data copied into a ring of shared buffers (the maps follow the gaze, so they are copied every frame; the ring is one slot deeper than the frames in flight because the depth pyramid carries its map into the next frame's culls). renderInfo.viewPort stays the texture size — the physical size — so every sizeable resource is allocated at it, exactly as before.
  • After the graph is built, applyXRFoveationToSceneRenderPassDescriptors attaches the eye's map to the descriptors of the passes that rasterize the scene at drawable size — the G-buffer + light pass (offscreenRenderPassDescriptor, MSAA included), the deferred colour that transparency, wireframe, spatial/muscle debug and the model-surface extension draw into, environment/sky/grid, the Gaussian splats, the gizmos — and the two ad-hoc depth descriptors (mesh occluder shells, wireframe occlusion depth). Each of those encoders sets the eye's viewport (applyXRFoveationViewport): the default viewport is the texture's size, which would draw the scene into a corner of the screen. Render extensions that draw into the scene targets get the viewport through SceneRenderTargetAccess.
  • The full-screen passes (SSAO, post-processing, pre-composite, look, FXAA/SMAA, the output transform into the compositor's colour and depth textures) keep plain descriptors: they copy physical pixels one to one and the foveated layout is already baked into what they read. The TBDR light quad reads the G-buffer from the tile under each fragment, so it needs nothing.
  • Shaders that turn a texture position back into space now decode the map (rasterization_rate_map_decoder): SSAO rebuilds view positions from a depth texel's screen position rather than its UV (otherwise the periphery's geometry bends and shades false creases); the occlusion culls — hzbCullVisibleEntities, gaussianChunkCull and the per-splat test shared by gaussianFrustumCull and gaussianChunkDecodePreprocess — move each projected rect or point into the pyramid's physical layout before sampling it (hzbRemapRectToPhysical / hzbScreenUVToPhysicalUV in HZBOcclusion.h), using the map the pyramid was drawn through (HZBPyramidFrame.rateMapData), so a foveated frame keeps occlusion culling instead of switching it off.
  • The Gaussian fragment takes its offset from the splat centre from a varying interpolated across the quad instead of from [[position]], which a rate map places in the physical texture, not on the screen the centre was projected to. SmallObjectCulling measures its pixel threshold against the screen height.
  • GaussianChunkCullConstants grows by one simd_float4 (192 → 208 bytes); the layout tests are updated. Every kernel that declares a rate map buffer gets a small placeholder bound when there is none and reads it only when the sizes word says there is a map.

Without foveation (a layer configured without it, the simulator), drawable.rasterizationRateMaps is empty and the frame is drawn uniformly as before; macOS/iOS are untouched beyond the extra (unread) bindings.

Device result (Vision Pro, visionOS 26, Oche with foveation on, render quality 1.0, MSAA 4×)

  • Log: ✓ XR foveation: drawing through the compositor's rasterization rate maps, screen 6888×5525, textures 2624×2560 per eye — the eye's screen is 2.6× the texture's width, which is what a uniform layer could never afford.
  • Two headset rounds found two compositor rules the headers do not state, both fixed in this PR: a layer with foveation or a render quality must be queried with queryDrawables() (the old single-drawable call aborts: "BUG IN CLIENT: called cp_frame_query_drawable() which does not support setting render quality or fovea"), and under a rate map Metal reads renderTargetWidth/Height and the viewport in screen pixels (a physical-size target clipped the scene to a screen-fixed rectangle, different per eye; measured on a Mac GPU with a real rate map).
  • With those in, the picture is sharp where the eyes look (the developer's word: "gorgeous"), consistent in both eyes, wires and digits crisp next to SwiftUI windows. The only visible trace of foveation is a slight shimmer of thin edges in the periphery when standing close enough for the board to fill the view — the compositor's lower peripheral rate, which the gaze-tracked map moves away from wherever the eyes go.

Checklist

This project follows the Engineering Principles.

  • Relevant tests have been added or updated — XRFoveationTests (render tests): the rate map data the shaders decode, which descriptors carry the map and which do not, and the HZB cull dropping a box only once its rect is moved through the map into the pyramid's layout (a real rate map on the Mac GPU, the real kernel); layout pins for the grown constants struct.
  • Existing functionality and API compatibility have been considered — new API only: RenderingProperty.foveatedRendering, getFoveatedRenderingEnabled(), renderInfo.xrFoveation, XRFoveationFrame, XRRasterizationRateMapData, HZBPyramidFrame.rateMapData (defaulted).
  • Relevant builds and tests pass locally — swift build --build-tests, the touched render test suites, xcodebuild of UntoldEngineXR for the visionOS simulator, kernels rebuilt with ./buildkernels.sh for every platform.
  • Recoverable failures are handled appropriately — an eye whose map parameters cannot be copied is drawn uniformly with a warning; a map whose physical size differs from its texture is logged.
  • Significant architectural changes and trade-offs are explained below
  • Documentation has been updated where necessary — docs/Architecture/xrRenderingSystem.md, docs/API/UsingEngineAPI.md.
  • Performance-sensitive changes have been measured — GPU time per frame on the headset (Oche, foveation on, render quality 1.0, 2624×2560 per eye, .msaaTaa, the whole command buffer as the Controls line reports it): 12 ms on average, 9 ms to 18 ms; the compositor's 90 Hz budget is 11.1 ms, so quality 1.0 with 4× MSAA and the temporal resolve sits at the edge of it and the game's render quality setting is what trades it off.

Architectural notes / trade-offs

  • Where the map goes and where it does not. The rule is: a pass that rasterizes geometry in clip space draws through the map and takes the eye's screen-space viewport; a full-screen pass that reads a physical texture and writes a physical texture one to one does not touch the map (attaching it there would warp the quad and feed screen UVs to physical textures). The only full-screen pass that has to know is SSAO, because it reconstructs positions from UVs; everything else that samples the scene by UV (pre-composite, bloom, FXAA) is layout-agnostic.
  • Texture size vs screen size. renderInfo.viewPort keeps meaning "the size of the textures" so the existing allocation and resize path works unchanged; the screen size lives in renderInfo.xrFoveation. The pixel-based cull threshold is the one system that wanted the screen size.
  • Culls keep working under foveation rather than being disabled: the rect remap is two decoder calls per box, and the pyramid remembers the map it was drawn through, so a gaze move between frames does not misplace the test. The eye-0 test against the eye-1 pyramid was already an approximation (a few centimetres apart); the map adds nothing to that.
  • Not covered here: render extensions that rasterize geometry into their own viewport-scaled textures and composite them one to one would need the map on their descriptors too (RenderPassContext could carry it); the layered and shared layouts, which the stereo path does not use.

Javier Segura added 3 commits October 10, 2026 17:26
…te maps (foveation)

With foveation enabled in the layer configuration, visionOS hands each eye a
rasterization rate map and textures of its physical size; the engine ignored
the maps, so apps had to keep foveation off, which also forbids a render
quality above the platform default. The stereo path now attaches the eye's map
to every pass that rasterizes the scene at drawable size and sets the eye's
screen-space viewport there; the full-screen passes keep copying physical
pixels one to one. SSAO rebuilds view positions from a texel's screen position,
and the occlusion culls (mesh, Gaussian chunk, per-splat) move their projected
rects into the pyramid's physical layout through the map the pyramid was drawn
with, so culling stays on under foveation. The Gaussian fragment takes its
offset from the splat centre from a varying rather than from its position.
On by default when the drawable provides maps; `.foveatedRendering(.disabled)`
ignores them for diagnosis. Kernels rebuilt for every platform.
A layer configured with foveation or a render quality aborts the app when
the frame is queried with the single-drawable queryDrawable() ("BUG IN
CLIENT: called cp_frame_query_drawable() which does not support setting
render quality or fovea"). The frame now takes the drawable array on
visionOS 26 (the old call on visionOS 2), presents every drawable, and
draws the scene into a capture drawable only while its textures match the
frame's resources; otherwise it is presented cleared rather than
reallocating every sizeable resource twice a frame.
… map is attached

Under a rasterization rate map Metal reads a descriptor's renderTargetWidth
and renderTargetHeight, like the viewport, in screen pixels. The scene
descriptors carried the physical texture size there, which clipped every
foveated frame to the top-left physical-size corner of the screen: on the
headset a screen-fixed rectangle, different in each eye, outside which the
board vanished. The attached descriptors now leave the size at 0 (the
attachments' own) and get the texture size back when the map is detached;
the eye's viewport is taken from the map's screen size.
@miogds

miogds commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #1359, which carries this work linearised together with the rest of the headset rendering set.

@miogds miogds closed this Oct 14, 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