Repository navigation
[Bugfix] Glass keeps its place against its frame and in a headset - #1331
Conversation
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.
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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.
Summary
Three things seen on the Vision Pro with the BIM site, and one that followed from them:
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.
Cause and change
RenderPassestransparency pass: glass draws withsetDepthBiasof 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 materialpaneShowsItselfuses 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 betweenGLASS_CLEAR_UP_TO_ROUGHNESSandGLASS_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 namedUntoldEngine);UntoldComponentKitTests59 tests.xcodebuild -scheme UntoldEngine -destination 'generic/platform=visionOS Simulator': build succeeded. SwiftFormat lint of the changed files: clean.