From fea864ea812e7d34fe9a49a374f938496871374f Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 29 Mar 2026 10:50:59 -0700 Subject: [PATCH 1/5] fix(VR): underwater mask for partial submersion closes #620 --- .../Upscaling/UnderwaterMaskUpscalePS.hlsl | 68 ++++++++++++++++++- package/Shaders/Common/SharedData.hlsli | 13 +++- package/Shaders/Lighting.hlsl | 2 +- src/Features/Upscaling.cpp | 5 +- 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl b/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl index 3e1e4c63a1..53bf5f2581 100644 --- a/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl +++ b/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl @@ -14,6 +14,9 @@ struct PS_OUTPUT SamplerState LinearSampler : register(s0); Texture2D UnderwaterMask : register(t0); +# if defined(VR) +Texture2D SceneDepth : register(t1); +# endif cbuffer JitterCB : register(b0) { @@ -26,6 +29,69 @@ PS_OUTPUT main(PS_INPUT input) { PS_OUTPUT psout; +# if defined(VR) + // In VR the vanilla waterline draw (DrawIndexedInstanced, 2 instances) emits + // identical left-eye clip positions for both instances. The internal-res mask + // therefore only represents the left eye: the right-eye half of the buffer + // contains the tapered apex of the left-eye polygon, which is nearly all black. + // GetDynamicResolutionAdjustedScreenPosition then samples that black region for + // the right eye, making the entire right-eye underwater fog incorrect. + // + // Fix: reconstruct the mask analytically per-eye. For a horizontal water plane + // at height waterHeight, a pixel is "underwater" (mask = 1) when: + // - the camera itself is below the water surface, OR + // - the ray from the per-eye camera through this pixel points downward + // (rayDir.z < 0), meaning it looks below the water plane. + // This exactly reproduces what the vanilla waterline polygon approximates, + // but correctly per-eye. + + uint eyeIndex = (input.TexCoord.x >= 0.5) ? 1 : 0; + + // WaterData is a 5×5 grid centered on the camera; tile 12 (row 2, col 2) is + // always the camera's own tile. Pass eyeIndex so GetWaterData corrects the .w + // (water surface height) from eye-0 camera-relative Z into the current eye's frame. + // GetWaterData expects a camera-relative XY position; float3(0,0,0) is the camera + // itself, which always maps to the center tile (12). + float waterHeight = SharedData::GetWaterData(float3(0, 0, 0), eyeIndex).w; + + // Sentinel: -FLT_MAX means no water body is present in this tile. + if (waterHeight > -1e9) { + // Unpack from side-by-side stereo layout to per-eye UV [0, 1] + float2 eyeUV = float2(input.TexCoord.x * 2.0 - (float)eyeIndex, input.TexCoord.y); + + // Convert to NDC [-1, 1]. UV y=0 is the top of the screen; NDC y=+1 is the top. + float2 ndc = float2(eyeUV.x * 2.0 - 1.0, 1.0 - eyeUV.y * 2.0); + + // Sample the scene depth. SceneDepth is depthCopy (t1), explicitly bound + // by the C++ pass. ConvertUVToSampleCoord handles stereo layout and dynamic + // resolution. + float depth = SceneDepth.Load(SharedData::ConvertUVToSampleCoord(eyeUV, eyeIndex)).x; + + if (depth > 0.0) { + // Geometry pixel: reconstruct world position from depth. + // CameraViewProjInverse[eyeIndex] maps clip-space back to the per-eye + // camera-relative world space. waterHeight has been adjusted to the same + // frame, so the comparison is exact for both eyes. + float4 worldPos = mul(FrameBuffer::CameraViewProjInverse[eyeIndex], float4(ndc, depth, 1.0)); + worldPos /= worldPos.w; + psout.UnderwaterMask = (worldPos.z < waterHeight) ? 1.0 : 0.0; + } else { + // depth == 0: sky / unrendered pixels (reversed-Z depth clear value). + // Unproject to obtain the per-pixel ray direction and decide based on that. + float4 worldFarPos = mul(FrameBuffer::CameraViewProjInverse[eyeIndex], float4(ndc, 0.0, 1.0)); + worldFarPos /= worldFarPos.w; + float3 rayDir = normalize(worldFarPos.xyz); + // Per-eye waterHeight > 0 means the water surface is above THIS eye's camera + // (eye is below water); <= 0 means the eye camera is above the water surface. + psout.UnderwaterMask = (waterHeight > 0.0 || rayDir.z < 0.0) ? 1.0 : 0.0; + } + return psout; + } + // No water tile in range: fall through to the standard sampler path. + // The left-eye result from the vanilla mask is still accurate here; the right-eye + // will be approximate, but in the absence of nearby water the visual impact is nil. +# endif + float2 originalUV = FrameBuffer::GetDynamicResolutionAdjustedScreenPosition(input.TexCoord); // Remove jitter offset to get the correct sampling coordinates @@ -40,4 +106,4 @@ PS_OUTPUT main(PS_INPUT input) return psout; } -#endif \ No newline at end of file +#endif diff --git a/package/Shaders/Common/SharedData.hlsli b/package/Shaders/Common/SharedData.hlsli index 6384330c2b..c12eff927d 100644 --- a/package/Shaders/Common/SharedData.hlsli +++ b/package/Shaders/Common/SharedData.hlsli @@ -314,7 +314,11 @@ namespace SharedData return GetScreenDepth(depth); } - float4 GetWaterData(float3 worldPosition) + // Returns water data for the tile containing worldPosition (camera-relative XY). + // The .w component (water surface height) is stored in C++ as camera-relative Z of + // eye 0 (left eye). Pass eyeIndex to have .w corrected into the current eye's + // camera-relative frame; defaults to 0 (no correction, backwards-compatible). + float4 GetWaterData(float3 worldPosition, uint eyeIndex = 0) { float2 cellF = (((worldPosition.xy + FrameBuffer::CameraPosAdjust[0].xy)) / 4096.0) + 64.0; // always positive int2 cellInt; @@ -331,6 +335,13 @@ namespace SharedData [flatten] if (cellInt.x < 5 && cellInt.x >= 0 && cellInt.y < 5 && cellInt.y >= 0) waterData = WaterData[waterTile]; + +# if defined(VR) + // Correct .w from eye-0 camera-relative Z to the current eye's camera-relative Z. + // No-op when eyeIndex == 0 (both terms are identical). + waterData.w += FrameBuffer::CameraPosAdjust[0].z - FrameBuffer::CameraPosAdjust[eyeIndex].z; +# endif + return waterData; } diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index 3c149f0d24..a3511a3f28 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -2320,7 +2320,7 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) # endif - float4 waterData = SharedData::GetWaterData(input.WorldPosition.xyz); + float4 waterData = SharedData::GetWaterData(input.WorldPosition.xyz, eyeIndex); float waterHeight = waterData.w; float waterRoughnessSpecular = 1; diff --git a/src/Features/Upscaling.cpp b/src/Features/Upscaling.cpp index f91f1e67b4..b1f9078a67 100644 --- a/src/Features/Upscaling.cpp +++ b/src/Features/Upscaling.cpp @@ -851,6 +851,8 @@ ID3D11PixelShader* Upscaling::GetUnderwaterMaskUpscalePS() if (!underwaterMaskUpscalePS) { logger::debug("Compiling UnderwaterMaskPS.hlsl"); std::vector> defines = { { "PSHADER", "" } }; + if (globals::game::isVR) + defines.push_back({ "VR", "" }); underwaterMaskUpscalePS.attach((ID3D11PixelShader*)Util::CompileShader(L"Data/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl", defines, "ps_5_0")); } @@ -1850,7 +1852,8 @@ void Upscaling::UpscaleDepth() context->OMSetDepthStencilState(nullptr, 0x00); - ID3D11ShaderResourceView* srvs[] = { underwaterMask.SRVCopy }; + // t0: vanilla mask copy, t1: depth buffer (for VR per-eye analytical mask) + ID3D11ShaderResourceView* srvs[] = { underwaterMask.SRVCopy, depthCopy.depthSRV }; context->PSSetShaderResources(0, ARRAYSIZE(srvs), srvs); ID3D11RenderTargetView* rtvs[] = { underwaterMask.RTV }; From 6fd163666091466c8d0547fdc62596d51ef5cbae Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Fri, 3 Apr 2026 23:35:28 -0700 Subject: [PATCH 2/5] fix: identify water surface border for mask --- .../Upscaling/UnderwaterMaskUpscalePS.hlsl | 21 +++++++++++++++++-- src/Features/Upscaling.cpp | 18 +++++++++++----- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl b/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl index 53bf5f2581..6066d55060 100644 --- a/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl +++ b/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl @@ -71,10 +71,27 @@ PS_OUTPUT main(PS_INPUT input) // Geometry pixel: reconstruct world position from depth. // CameraViewProjInverse[eyeIndex] maps clip-space back to the per-eye // camera-relative world space. waterHeight has been adjusted to the same - // frame, so the comparison is exact for both eyes. + // frame, so the comparison is correct for both eyes. float4 worldPos = mul(FrameBuffer::CameraViewProjInverse[eyeIndex], float4(ndc, depth, 1.0)); worldPos /= worldPos.w; - psout.UnderwaterMask = (worldPos.z < waterHeight) ? 1.0 : 0.0; + // kSurfaceBias (Skyrim world units, ~1 unit ≈ 1.4 cm) anchors the mask + // threshold relative to the flat waterHeight plane to absorb wave-vertex + // displacement (measured max trough ≈ 2.92 units; 3.5 gives margin). + // + // The threshold direction depends on view orientation: + // Looking UP (worldPos.z > 0, pixel above camera in world space): + // Camera is below the surface viewing it from underneath. + // Expand threshold upward by +kSurfaceBias so the entire wave surface + // (crests and troughs alike) is included in the masked region. + // Looking DOWN (worldPos.z <= 0, pixel below or level with camera): + // The surface is seen from above or the camera is above water. + // Shrink threshold downward by -kSurfaceBias so the surface itself + // is excluded from the mask (no fog on the surface seen from above). + static const float kSurfaceBias = 3.5; + bool lookingUp = worldPos.z > 0.0; + bool cameraUnderwater = waterHeight > 0.0; + float threshold = (cameraUnderwater && lookingUp) ? waterHeight + kSurfaceBias : waterHeight - kSurfaceBias; + psout.UnderwaterMask = (worldPos.z < threshold) ? 1.0 : 0.0; } else { // depth == 0: sky / unrendered pixels (reversed-Z depth clear value). // Unproject to obtain the per-pixel ray direction and decide based on that. diff --git a/src/Features/Upscaling.cpp b/src/Features/Upscaling.cpp index b1f9078a67..ea19002389 100644 --- a/src/Features/Upscaling.cpp +++ b/src/Features/Upscaling.cpp @@ -1837,10 +1837,12 @@ void Upscaling::UpscaleDepth() context->PSSetShader(depthUpscalePS, nullptr, 0); context->Draw(3, 0); - // Depth copy is also used on VR. - if (globals::game::isVR) { - copyIfNonAliased(depthCopy.texture, depth.texture); - } + // kMAIN now has the min-filtered upscaled depth; kMAIN_COPY still holds the + // original (pre-upscale) depth. The underwater mask needs the original depth: + // the min-filter spreads depth=0 (sky/far-plane in reversed-Z) into water-surface + // pixels at the water-sky boundary. Those pixels would incorrectly fall into the + // depth=0 / sky path and get marked as underwater. + // Defer the VR re-copy until AFTER the underwater mask draw. } { @@ -1852,7 +1854,8 @@ void Upscaling::UpscaleDepth() context->OMSetDepthStencilState(nullptr, 0x00); - // t0: vanilla mask copy, t1: depth buffer (for VR per-eye analytical mask) + // t0: vanilla mask copy, t1: original depth (for VR per-eye analytical mask). + // depthCopy still holds the original pre-upscale depth here (VR re-copy deferred). ID3D11ShaderResourceView* srvs[] = { underwaterMask.SRVCopy, depthCopy.depthSRV }; context->PSSetShaderResources(0, ARRAYSIZE(srvs), srvs); @@ -1863,6 +1866,11 @@ void Upscaling::UpscaleDepth() context->Draw(3, 0); } + // Now propagate the upscaled depth to kMAIN_COPY so downstream VR passes see it. + if (globals::game::isVR) { + copyIfNonAliased(depthCopy.texture, depth.texture); + } + ID3D11ShaderResourceView* nullPSResources[3] = { nullptr, nullptr, nullptr }; context->PSSetShaderResources(0, ARRAYSIZE(nullPSResources), nullPSResources); From c2f655697650b0dcfd151e83b9a6035a63cfc4c0 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sat, 4 Apr 2026 17:45:23 -0700 Subject: [PATCH 3/5] chore(upscaling): bump version --- features/Upscaling/Shaders/Features/Upscaling.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/Upscaling/Shaders/Features/Upscaling.ini b/features/Upscaling/Shaders/Features/Upscaling.ini index 8dc1735318..badc614325 100644 --- a/features/Upscaling/Shaders/Features/Upscaling.ini +++ b/features/Upscaling/Shaders/Features/Upscaling.ini @@ -1,2 +1,2 @@ [Info] -Version = 1-3-0 \ No newline at end of file +Version = 1-3-1 \ No newline at end of file From a21fa4cb73729f26147a337570dbf281eac1b24f Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sat, 4 Apr 2026 23:17:47 -0700 Subject: [PATCH 4/5] chore: address ai comments --- .../Upscaling/UnderwaterMaskUpscalePS.hlsl | 32 +++++++++---------- package/Shaders/Common/Math.hlsli | 4 +++ 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl b/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl index 6066d55060..66dfd559be 100644 --- a/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl +++ b/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl @@ -2,6 +2,7 @@ #if defined(PSHADER) # include "Common/FrameBuffer.hlsli" +# include "Common/Math.hlsli" # include "Common/SharedData.hlsli" typedef VS_OUTPUT PS_INPUT; @@ -29,6 +30,14 @@ PS_OUTPUT main(PS_INPUT input) { PS_OUTPUT psout; + float2 originalUV = FrameBuffer::GetDynamicResolutionAdjustedScreenPosition(input.TexCoord); + + // Remove jitter offset to get the correct sampling coordinates + float2 uv = originalUV - (jitter * SharedData::BufferDim.zw); + + // Clamp within bounds + uv = clamp(uv, 0.0, FrameBuffer::DynamicResolutionParams1.xy); + # if defined(VR) // In VR the vanilla waterline draw (DrawIndexedInstanced, 2 instances) emits // identical left-eye clip positions for both instances. The internal-res mask @@ -54,20 +63,19 @@ PS_OUTPUT main(PS_INPUT input) // itself, which always maps to the center tile (12). float waterHeight = SharedData::GetWaterData(float3(0, 0, 0), eyeIndex).w; - // Sentinel: -FLT_MAX means no water body is present in this tile. - if (waterHeight > -1e9) { + // GetWaterData returns INT_MIN (~-2.147e9) when the tile is outside the 5x5 grid. + if (waterHeight > WATER_HEIGHT_NO_TILE_SENTINEL) { // Unpack from side-by-side stereo layout to per-eye UV [0, 1] float2 eyeUV = float2(input.TexCoord.x * 2.0 - (float)eyeIndex, input.TexCoord.y); // Convert to NDC [-1, 1]. UV y=0 is the top of the screen; NDC y=+1 is the top. float2 ndc = float2(eyeUV.x * 2.0 - 1.0, 1.0 - eyeUV.y * 2.0); - // Sample the scene depth. SceneDepth is depthCopy (t1), explicitly bound - // by the C++ pass. ConvertUVToSampleCoord handles stereo layout and dynamic - // resolution. - float depth = SceneDepth.Load(SharedData::ConvertUVToSampleCoord(eyeUV, eyeIndex)).x; + // Sample depth using the shared de-jittered stereo UV (already DR-adjusted above). + // uv is in stereo space so no ConvertUVToSampleCoord round-trip is needed. + float depth = SceneDepth.Load(int3(uv * SharedData::BufferDim.xy, 0)).x; - if (depth > 0.0) { + if (depth > EPSILON_DEPTH_SKY) { // Geometry pixel: reconstruct world position from depth. // CameraViewProjInverse[eyeIndex] maps clip-space back to the per-eye // camera-relative world space. waterHeight has been adjusted to the same @@ -93,7 +101,7 @@ PS_OUTPUT main(PS_INPUT input) float threshold = (cameraUnderwater && lookingUp) ? waterHeight + kSurfaceBias : waterHeight - kSurfaceBias; psout.UnderwaterMask = (worldPos.z < threshold) ? 1.0 : 0.0; } else { - // depth == 0: sky / unrendered pixels (reversed-Z depth clear value). + // depth <= EPSILON_DEPTH_SKY: sky / unrendered pixels (reversed-Z depth clear value). // Unproject to obtain the per-pixel ray direction and decide based on that. float4 worldFarPos = mul(FrameBuffer::CameraViewProjInverse[eyeIndex], float4(ndc, 0.0, 1.0)); worldFarPos /= worldFarPos.w; @@ -109,14 +117,6 @@ PS_OUTPUT main(PS_INPUT input) // will be approximate, but in the absence of nearby water the visual impact is nil. # endif - float2 originalUV = FrameBuffer::GetDynamicResolutionAdjustedScreenPosition(input.TexCoord); - - // Remove jitter offset to get the correct sampling coordinates - float2 uv = originalUV - (jitter * SharedData::BufferDim.zw); - - // Clamp within bounds - uv = clamp(uv, 0.0, FrameBuffer::DynamicResolutionParams1.xy); - // Upscale using linear sampling with jitter-corrected coordinates psout.UnderwaterMask = UnderwaterMask.SampleLevel(LinearSampler, uv, 0); diff --git a/package/Shaders/Common/Math.hlsli b/package/Shaders/Common/Math.hlsli index f4098d8db7..29c5420e4b 100644 --- a/package/Shaders/Common/Math.hlsli +++ b/package/Shaders/Common/Math.hlsli @@ -10,6 +10,10 @@ #define DEPTH_SKY_SENTINEL 999999.0f // Linearized depth sentinel for sky/unmapped pixels (beyond any real geometry) +// GetWaterData returns .w = INT_MIN (~-2.147e9) when the tile is out of the 5x5 grid. +// Use this threshold to test for "no water body present": waterHeight > WATER_HEIGHT_NO_TILE_SENTINEL. +#define WATER_HEIGHT_NO_TILE_SENTINEL -1e9f + namespace Math { static const float4x4 IdentityMatrix = { From 036311793f0ecc6fe4fe36b63fadbb0d98bffa70 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 5 Apr 2026 20:36:15 -0700 Subject: [PATCH 5/5] chore: remove comments --- src/Features/Upscaling.cpp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Features/Upscaling.cpp b/src/Features/Upscaling.cpp index ea19002389..2945461761 100644 --- a/src/Features/Upscaling.cpp +++ b/src/Features/Upscaling.cpp @@ -1836,13 +1836,6 @@ void Upscaling::UpscaleDepth() context->PSSetShader(depthUpscalePS, nullptr, 0); context->Draw(3, 0); - - // kMAIN now has the min-filtered upscaled depth; kMAIN_COPY still holds the - // original (pre-upscale) depth. The underwater mask needs the original depth: - // the min-filter spreads depth=0 (sky/far-plane in reversed-Z) into water-surface - // pixels at the water-sky boundary. Those pixels would incorrectly fall into the - // depth=0 / sky path and get marked as underwater. - // Defer the VR re-copy until AFTER the underwater mask draw. } {