diff --git a/features/Upscaling/Shaders/Features/Upscaling.ini b/features/Upscaling/Shaders/Features/Upscaling.ini index 7c1498e32b..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 +[Info] +Version = 1-3-1 \ No newline at end of file diff --git a/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl b/features/Upscaling/Shaders/Upscaling/UnderwaterMaskUpscalePS.hlsl index 3e1e4c63a1..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; @@ -14,6 +15,9 @@ struct PS_OUTPUT SamplerState LinearSampler : register(s0); Texture2D UnderwaterMask : register(t0); +# if defined(VR) +Texture2D SceneDepth : register(t1); +# endif cbuffer JitterCB : register(b0) { @@ -34,10 +38,89 @@ PS_OUTPUT main(PS_INPUT input) // 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 + // 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; + + // 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 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 > 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 + // frame, so the comparison is correct for both eyes. + float4 worldPos = mul(FrameBuffer::CameraViewProjInverse[eyeIndex], float4(ndc, depth, 1.0)); + worldPos /= worldPos.w; + // 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 <= 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; + 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 + // Upscale using linear sampling with jitter-corrected coordinates psout.UnderwaterMask = UnderwaterMask.SampleLevel(LinearSampler, uv, 0); return psout; } -#endif \ No newline at end of file +#endif 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 = { 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..2945461761 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")); } @@ -1834,11 +1836,6 @@ 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); - } } { @@ -1850,7 +1847,9 @@ void Upscaling::UpscaleDepth() context->OMSetDepthStencilState(nullptr, 0x00); - ID3D11ShaderResourceView* srvs[] = { underwaterMask.SRVCopy }; + // 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); ID3D11RenderTargetView* rtvs[] = { underwaterMask.RTV }; @@ -1860,6 +1859,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);