Skip to content

[Bugfix] Glass keeps its place against its frame and in a headset - #1331

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

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

Conversation

@miogds

@miogds miogds commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Three things seen on the Vision Pro with the BIM site, and one that followed from them:

  • A pane modelled into its frame shares a plane with the frame, and the two fought for depth along the pane's edges at every move of the head: glass now draws a hair behind where it is, so the frame wins wherever they coincide. On a Mac the same fight shows as a noisy edge when the camera moves.
  • The side of a pane the viewer sees was decided from the smooth shading normal, which bends at a pane's edges, so an edge pixel could count as the far side for one eye and the near side for the other: it is decided from the face itself now, with the vertex normal only choosing which way the face points.
  • The shading normal of a pane now always faces the viewer, whichever of the two goes draws the face.
  • In a headset the compositor reprojects each pixel by the depth it finds. A pane that mostly shows itself (dark, frosted) now writes its depth, so its tint and reflections stay on the pane; a clear pane leaves the depth of what is seen through it, which is what the eye follows. Before, every pane was reprojected with the depth of what was behind it, and the dark car windows flickered.

Before and after

A glass cube with a glowing block whose near face lies in the plane of the cube's near face, rendered by the engine's test harness with dark glass so the difference is plain: before, the pane covers the block wherever the two coincide; with the change, the block wins its plane in every pixel.

A pane and a block in one plane: before and with the change

Cause and change

  • RenderPasses transparency pass: glass draws with setDepthBias of 4 units of depth resolution and 2 per unit of slope, away from the viewer under either depth convention; other blended draws reset it. In XR stereo mode outside passthrough, a glass draw whose material paneShowsItself uses the pass's depth-writing state (the one passthrough already uses for every transparent draw), the others the pass's own state.
  • Material.paneShowsItself: less than half of the light crosses the pane, from coverage, transmission, the clear share by roughness (the shader's smoothstep between GLASS_CLEAR_UP_TO_ROUGHNESS and GLASS_FROSTED_FROM_ROUGHNESS) and what a non-metal of that base colour lets cross.
  • TransparencyShader.metal: the far/near decision uses the derivative (face) normal aligned to the vertex normal, falling back to the shading normal for a degenerate face; the shading normal is turned to face the viewer whenever a two-go draw is on.

Verification

  • GlassShadingTests: 19 tests, the 17 of [Feature] Glass: a material's transmission shows what is behind it and keeps its reflections #1308 unchanged plus two new ones. testAPaneInThePlaneOfASolidSurfaceShowsTheSolidSurface: a glowing block whose near face lies in the plane of the glass cube's near face shows the block in every pixel of a 24-pixel window; with the bias set to zero it fails on the mean colour and on single pixels. testAPaneWritesItsDepthInAHeadsetWhenItMostlyShowsItself: clear window glass no, smoked car glass yes, frosted yes, a pane half there by its alpha no, light grey glass at half transmission yes.
  • swift test --filter UntoldEngineTests: 1,692 passed, 2 skipped (the external render extension package test aside, which needs a checkout named UntoldEngine); UntoldComponentKitTests 59 tests.
  • Strict-concurrency build: 0 warnings. xcodebuild -scheme UntoldEngine -destination 'generic/platform=visionOS Simulator': build succeeded. SwiftFormat lint of the changed files: clean.
  • On the Vision Pro, before this branch, on the try-out it comes from: the door edges of the BIM site stopped fighting their frames with the bias; the dark car windows stopped flickering with the depth rule; what is seen through clear glass stayed in place.

Seen on an Apple Vision Pro with a BIM site, through the editor's Mac-rendered
preview, and fixed on a try-out before coming here.

A pane modelled into its frame shares a plane with the frame, and the two fought
for depth along the pane's edges at every move of the head (on a Mac, a noisy
edge whenever the camera moves). Glass now draws a few units of depth resolution
behind where it is, and two more per unit of slope, under either depth
convention, so the frame wins wherever the two coincide.

The side of a pane the viewer sees was decided from the smooth shading normal,
which bends at a pane's edges, so an edge pixel could count as the far side for
one eye and the near side for the other. The face itself decides now: the
derivative normal, with the vertex normal only choosing which way it points,
and the shading normal for a degenerate face. The shading normal of a pane
always faces the viewer, whichever of the two goes draws the face.

A headset's compositor reprojects every pixel by the depth it finds. A pane that
mostly shows itself (dark, frosted: less than half of the light crosses it,
Material.paneShowsItself) now writes its depth outside passthrough, so its tint
and reflections stay on the pane; a clear pane leaves the depth of what is seen
through it, which is what the eye follows. Before, every pane carried the depth
of what was behind it and dark car windows flickered.

Tests: a glowing block whose near face lies in the plane of a glass cube's near
face shows the block in every pixel of the middle (it fails without the bias);
the depth rule on clear, smoked, frosted, half-there and grey glass. The seven
shader libraries are rebuilt.
@miogds
miogds requested a review from untoldengine as a code owner October 10, 2026 10:33
Comment thread Sources/UntoldEngine/Mesh/Mesh.swift Outdated
let t = min(max((roughnessValue - GLASS_CLEAR_UP_TO_ROUGHNESS) / (GLASS_FROSTED_FROM_ROUGHNESS - GLASS_CLEAR_UP_TO_ROUGHNESS), 0), 1)
let clearShare = 1 - t * t * (3 - 2 * t)
let brightness = max(baseColorValue.x, max(baseColorValue.y, baseColorValue.z)).squareRoot()
let crosses = (1 - metallicValue) * brightness * 0.96

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nit: the 0.96 here is 1 - 0.04, echoing the dielectric F0 (environmentReflectance(float3(0.04), ...)) used in TransparencyShader.metal, just collapsed to its head-on value since this CPU-side check doesn't have a per-pixel NoV/roughness to work with — totally reasonable approximation. Only thing I'd suggest: since the PR comment already notes this heuristic has to stay in sync with the shader, maybe pull 0.96 out into a named constant like dielectricHeadOnTransmittance (or add a short comment pointing back to the shader's 0.04), so a future change to the shader's F0 doesn't silently leave this one behind.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the follow-up commit: the 0.96 is now Material.dielectricHeadOnTransmittance, defined as 1 - 0.04 with a comment that names the shader's environmentReflectance(float3(0.04), ...) as its source and says to keep the two in step. paneShowsItself reads it; the five depth-rule cases and the coplanar test pass as before.

untoldengine
untoldengine previously approved these changes Oct 10, 2026
…ule uses

The 0.96 in Material.paneShowsItself is one minus the dielectric reflectance of
0.04 that TransparencyShader.metal takes as F0, collapsed to its head-on value
since the rule has no view angle or roughness per pixel. It is a named constant
now, dielectricHeadOnTransmittance, with a comment pointing at the shader, so a
change of the shader's F0 does not leave the rule behind.
@untoldengine
untoldengine merged commit 144863a into untoldengine:develop Oct 10, 2026
4 checks passed
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