Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions features/Upscaling/Shaders/Features/Upscaling.ini
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[Info]
Version = 1-3-0
[Info]
Version = 1-3-1
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#if defined(PSHADER)
# include "Common/FrameBuffer.hlsli"
# include "Common/Math.hlsli"
# include "Common/SharedData.hlsli"

typedef VS_OUTPUT PS_INPUT;
Expand All @@ -14,6 +15,9 @@ struct PS_OUTPUT
SamplerState LinearSampler : register(s0);

Texture2D<float> UnderwaterMask : register(t0);
# if defined(VR)
Texture2D<float> SceneDepth : register(t1);
# endif

cbuffer JitterCB : register(b0)
{
Expand All @@ -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;
Comment thread
alandtse marked this conversation as resolved.
}
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
#endif
4 changes: 4 additions & 0 deletions package/Shaders/Common/Math.hlsli
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
13 changes: 12 additions & 1 deletion package/Shaders/Common/SharedData.hlsli
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion package/Shaders/Lighting.hlsl
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 10 additions & 6 deletions src/Features/Upscaling.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,8 @@ ID3D11PixelShader* Upscaling::GetUnderwaterMaskUpscalePS()
if (!underwaterMaskUpscalePS) {
logger::debug("Compiling UnderwaterMaskPS.hlsl");
std::vector<std::pair<const char*, const char*>> 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"));
}

Expand Down Expand Up @@ -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);
}
}

{
Expand All @@ -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 };
Expand All @@ -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);

Expand Down
Loading