Skip to content

[Bugfix] Shade each XR eye from its own position - #1285

Merged
untoldengine merged 2 commits into
untoldengine:developfrom
miolabs:bugfix/xr_per_eye_shading
Oct 10, 2026
Merged

untoldengine merged 2 commits into
untoldengine:developfrom
miolabs:bugfix/xr_per_eye_shading

Conversation

@miogds

@miogds miogds commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

In XR every shading pass used the head-centre camera position for its view vector, so both eyes were lit as if seen from between them. Specular highlights and Fresnel then carry no stereo disparity: up close they read as painted onto the surface instead of sitting at the depth of a reflection. Each eye is now shaded from its own position.

Checked on an Apple Vision Pro through the editor's Mac-rendered preview (see Verification).

Cause

UntoldEngineXR.syncStreamingCameraPosition sets CameraComponent.localPosition to the headset centre once a frame (setXRCameraWorldPosition). That is right for streaming, where 3 cm do not matter. But the light pass and the other shading passes read the same value through SceneRootTransform.effectiveCameraPosition(cameraComponent.localPosition), while renderXR only updated each eye's viewSpace.

Changes

  • renderXR stores the position of the eye it draws, taken from that eye's view matrix (eyePosition(fromViewMatrix:)), in renderInfo.xrEyeCameraPosition. The non-XR draw(in:) clears it.
  • shadingCameraPosition(_:) returns that eye position, or the camera's own position outside XR, folded through the scene root as before. It replaces the 17 shading reads: the light pass, the model and batch uniforms of the geometry, shadow-free, transparency and debug passes, the sky, and render-extension surfaces.
  • Unchanged on purpose:
    • Streaming, LOD, octree and culling keep the head centre.
    • Gaussian splats keep it too: their colour is evaluated once per frame in the preprocess shared by both eyes, and doing that per eye would double its cost.
  • Outside XR nothing changes: the value is nil there, and the helper returns exactly what the passes used before.

Verification

  • New ShadingCameraPositionTests (4): eye position from a rotated, translated view matrix; camera position outside XR; the eye, not the head centre, in XR; the scene root applied to the eye position.

  • swift test --filter UntoldEngineTests on the rebased branch: 1,673 passed, 2 skipped (the external render extension package test run from a copy named UntoldEngine, where it passes). Strict-concurrency build: 0 warnings. xcodebuild -scheme UntoldEngine -destination 'generic/platform=visionOS Simulator': build succeeded.

  • xcodebuild -scheme UntoldEngine -destination 'generic/platform=visionOS Simulator': build succeeded.

  • No new SwiftFormat findings in the changed files.

  • Checked on an Apple Vision Pro (the editor's preview, the Mac rendering both eyes), on the glass and paint of parked cars at arm's length with an HDR environment and a directional light, closing each eye in turn and switching the eye position off and on in the same build. With the head-centre position the reflection looks painted onto the glass; with each eye's own position it sits at its depth, and at some angles the two eyes see it slightly differently, as on real glass. Nothing else differed between the two runs.

Javier Segura added 2 commits October 9, 2026 18:05
In XR the lighting, model, batch, transparency, sky and render-extension
passes took their view vector from CameraComponent.localPosition, which
UntoldEngineXR sets to the head centre once a frame for streaming
(setXRCameraWorldPosition). Both eyes were shaded as if seen from
between them, so specular highlights and Fresnel had no stereo
disparity and read as painted on the surface up close.

renderXR now stores the position of the eye it draws, taken from that
eye's view matrix, in renderInfo.xrEyeCameraPosition, and the shading
passes read shadingCameraPosition(_:), which returns it (through the
scene root, like before) or the camera's position outside XR; the
non-XR draw clears it. Streaming, LOD, octree and culling keep the
head centre. Gaussian splats keep it too: their colour is evaluated
once per frame for both eyes in the shared preprocess.
SwiftFormat 0.60.1 (the version CI pins) wants a doc comment on a lone
property declaration; the lint job failed on it.
@miogds
miogds force-pushed the bugfix/xr_per_eye_shading branch from 90ed292 to 534d3e2 Compare October 9, 2026 16:14
@miogds
miogds marked this pull request as ready for review October 9, 2026 16:14
@miogds
miogds requested a review from untoldengine as a code owner October 9, 2026 16:14
@untoldengine
untoldengine merged commit 0b2863d into untoldengine:develop Oct 10, 2026
4 checks passed
miogds added a commit to miolabs/UntoldEngine that referenced this pull request Oct 10, 2026
Sync fork develop with upstream: per-eye shading, LOD hidden parts, IBL diffuse (untoldengine#1285, untoldengine#1324, untoldengine#1329)
@miogds
miogds deleted the bugfix/xr_per_eye_shading branch October 10, 2026 08:57
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.

2 participants