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
74 changes: 58 additions & 16 deletions features/Screen Space GI/Shaders/ScreenSpaceGI/stereoSync.cs.hlsl
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
// Stereo Sync - Bilateral blend of SSGI buffers between eyes
//
// Reprojects each pixel to the other eye and blends AO/IL based on depth
// agreement with back-check validation. Runs after the SSGI blur to reduce
// per-eye GI disparities.
// agreement. Runs after the SSGI blur to reduce per-eye GI disparities.
//
// Based on: Shi, Billeter, Eisemann 2022, "Stereo-consistent screen-space
// ambient occlusion" https://eprints.whiterose.ac.uk/id/eprint/187713/
Expand All @@ -22,9 +21,33 @@ RWTexture2D<float> outAo : register(u0);
RWTexture2D<float4> outIlY : register(u1);
RWTexture2D<float2> outIlCoCg : register(u2);

static const float kDepthSigma = 0.01;
static const float kMaxBlend = 0.5;
static const float kBackCheckThreshold = 8.0;
static const float kDepthSigma = 0.01; // Bilateral depth tolerance (NDC): surfaces within this range are considered the same and blended
static const float kMaxBlend = 0.5; // Maximum stereo blend weight; 0.5 gives equal weighting between eyes
static const float kEdgeRelThreshold = 0.5; // Relative linear-depth difference above which a pixel is a depth discontinuity (50% change)
static const float kMaskDepth = 0.01; // Linear depth sentinel: values below this are outside the HMD lens area
static const int kEdgeMargin = 2; // Neighbor offset (pixels) for destination edge + mask boundary check

// Writes all output channels from the source buffers (passthrough / no-blend path).
void Passthrough(uint2 dtid)
{
outAo[dtid] = srcAo[dtid];
outIlY[dtid] = srcIlY[dtid];
outIlCoCg[dtid] = srcIlCoCg[dtid];
}

// Samples four depth neighbors in a cross pattern (±step.x, ±step.y) around centerUV,
// scaled by texScale to map from output UV space to texture sample coords.
// centerUV is clamped to eyeIndex's half of the stereo buffer before offsetting
// to prevent neighbor reads from crossing the x=0.5 seam into the other eye.
float4 SampleCrossDepths(float2 centerUV, float2 step, float2 texScale, uint eyeIndex)
{
float2 uv = Stereo::ClampToEyeUV(centerUV, eyeIndex);
return float4(
srcDepth.SampleLevel(samplerPointClamp, (uv + float2(step.x, 0)) * texScale, RES_MIP),
srcDepth.SampleLevel(samplerPointClamp, (uv + float2(-step.x, 0)) * texScale, RES_MIP),
srcDepth.SampleLevel(samplerPointClamp, (uv + float2(0, step.y)) * texScale, RES_MIP),
srcDepth.SampleLevel(samplerPointClamp, (uv + float2(0, -step.y)) * texScale, RES_MIP));
}
Comment thread
alandtse marked this conversation as resolved.

[numthreads(8, 8, 1)] void main(uint2 dtid : SV_DispatchThreadID) {
const float2 outFrameDim = OUT_FRAME_DIM;
Expand All @@ -40,9 +63,18 @@ static const float kBackCheckThreshold = 8.0;
// 0.0 = mask (outside lens area). FP_Z = first-person hands threshold (~18.0).
float depth = srcDepth.SampleLevel(samplerPointClamp, uv * frameScale, RES_MIP);
if (depth < FP_Z) {
outAo[dtid] = srcAo[dtid];
outIlY[dtid] = srcIlY[dtid];
outIlCoCg[dtid] = srcIlCoCg[dtid];
Passthrough(dtid);
return;
}

// Source edge detection: skip stereo sync at depth discontinuities.
// Uses a relative threshold since depth is linear view-space (not NDC).
// Placed before rawDepth conversion and reprojection to save VP matrix work
// for edge pixels.
float2 pixelStep = 1.0 / outFrameDim;
float4 srcNeighborDepths = SampleCrossDepths(uv, pixelStep, frameScale, eyeIndex);
if (Stereo::MaxDepthDiff(depth, srcNeighborDepths) / max(depth, 1.0) > kEdgeRelThreshold) {
Passthrough(dtid);
return;
}

Expand All @@ -54,23 +86,33 @@ static const float kBackCheckThreshold = 8.0;
Stereo::StereoBilateralResult r = Stereo::ReprojectToOtherEye(uv, rawDepth, eyeIndex, outFrameDim);

if (!r.valid) {
outAo[dtid] = srcAo[dtid];
outIlY[dtid] = srcIlY[dtid];
outIlCoCg[dtid] = srcIlCoCg[dtid];
Passthrough(dtid);
return;
}

float otherLinearDepth = srcDepth.SampleLevel(samplerPointClamp, r.otherStereoUV * frameScale, RES_MIP);
if (otherLinearDepth < FP_Z) {
outAo[dtid] = srcAo[dtid];
outIlY[dtid] = srcIlY[dtid];
outIlCoCg[dtid] = srcIlCoCg[dtid];
Passthrough(dtid);
return;
}

// Destination edge detection: skip if the reprojected pixel is near the HMD mask
// boundary or at a depth discontinuity in the other eye. Due to VR parallax the
// arm silhouette appears at a different screen position per eye, so the reprojection
// can cross a boundary invisible from this eye's perspective.
float2 marginStep = float(kEdgeMargin) / outFrameDim;
float4 otherNeighborDepths = SampleCrossDepths(r.otherStereoUV, marginStep, frameScale, 1 - eyeIndex);
if (any(otherNeighborDepths < kMaskDepth) ||
Stereo::MaxDepthDiff(otherLinearDepth, otherNeighborDepths) / max(otherLinearDepth, 1.0) > kEdgeRelThreshold) {
Passthrough(dtid);
return;
}

float otherRawDepth = (SharedData::CameraData.x - SharedData::CameraData.w / otherLinearDepth) / SharedData::CameraData.z;

// Use raw depth for back-check reprojection (required) and bilateral weight (consistent with StereoBlendCS)
Stereo::FinalizeStereoBlend(r, uv, rawDepth, otherRawDepth, eyeIndex, outFrameDim, kDepthSigma, kMaxBlend, kBackCheckThreshold);
// Back-check disabled: source + destination edge detection covers the occlusion
// boundary cases it was guarding, saving 2 VP matrix multiplies per blended pixel.
Stereo::FinalizeStereoBlend(r, uv, rawDepth, otherRawDepth, eyeIndex, outFrameDim, kDepthSigma, kMaxBlend, 0.0);

outAo[dtid] = lerp(srcAo[dtid], srcAo[r.otherPx], r.blendWeight);
outIlY[dtid] = lerp(srcIlY[dtid], srcIlY[r.otherPx], r.blendWeight);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,7 @@ cbuffer StereoSyncCB : register(b1)
static const float kDepthSigma = 0.01; // Bilateral depth tolerance (NDC): surfaces within this range are considered the same and blended
static const float kMaxBlend = 1.0; // Maximum stereo blend weight; reduce below 1.0 to soften the cross-eye contribution
static const float kEdgeDepthThreshold = 0.05; // NDC depth difference above which a pixel is considered a depth discontinuity and excluded from stereo sync

float MaxDepthDiff(float center, float4 neighbors)
{
return max(max(abs(center - neighbors.x), abs(center - neighbors.y)),
max(abs(center - neighbors.z), abs(center - neighbors.w)));
}
static const int kEdgeMargin = 2; // Neighbor offset (pixels) for destination edge + mask boundary check

// Depth-weighted 4-sample blur using a rotated Poisson disk.
// Uses dtid hash for per-pixel rotation to break structured patterns.
Expand Down Expand Up @@ -76,6 +71,17 @@ float BlurShadow(int2 dtid, float centerDepth)
return weight > 0.0 ? shadow / weight : SrcShadowTexture[dtid];
}

// Samples four depth neighbors in a cross pattern (±offset pixels) around center,
// clamped to eyeIndex's half of the packed stereo buffer to avoid seam contamination.
float4 SampleCrossDepths(int2 center, int offset, uint eyeIndex)
{
return float4(
SrcDepthTexture[Stereo::ClampToEyeBounds(center + int2(offset, 0), eyeIndex, FrameDim)],
SrcDepthTexture[Stereo::ClampToEyeBounds(center + int2(-offset, 0), eyeIndex, FrameDim)],
SrcDepthTexture[Stereo::ClampToEyeBounds(center + int2(0, offset), eyeIndex, FrameDim)],
SrcDepthTexture[Stereo::ClampToEyeBounds(center + int2(0, -offset), eyeIndex, FrameDim)]);
}

[numthreads(8, 8, 1)] void main(uint2 dtid : SV_DispatchThreadID) {
if (any(dtid >= uint2(FrameDim)))
return;
Expand Down Expand Up @@ -104,12 +110,8 @@ float BlurShadow(int2 dtid, float centerDepth)
// Skip stereo sync at depth discontinuities (arm/world silhouettes, object edges).
// Placed before the blur: the bilateral depth weighting zeroes out cross-edge
// samples, so the blur collapses to SrcShadowTexture[dtid] at these pixels anyway.
float4 edgeDepths = float4(
SrcDepthTexture[dtid + int2(1, 0)],
SrcDepthTexture[dtid + int2(-1, 0)],
SrcDepthTexture[dtid + int2(0, 1)],
SrcDepthTexture[dtid + int2(0, -1)]);
if (MaxDepthDiff(depth, edgeDepths) > kEdgeDepthThreshold) {
float4 edgeDepths = SampleCrossDepths(dtid, 1, eyeIndex);
if (Stereo::MaxDepthDiff(depth, edgeDepths) > kEdgeDepthThreshold) {
OutShadowTexture[dtid] = SrcShadowTexture[dtid];
return;
}
Expand Down Expand Up @@ -139,13 +141,8 @@ float BlurShadow(int2 dtid, float centerDepth)
// silhouette appears at a different screen position in each eye, so the
// reprojection can cross a boundary invisible from this eye's perspective.
// Reusing the same four neighbor reads covers both purposes at no extra cost.
static const int kEdgeMargin = 2;
float4 otherNeighbors = float4(
SrcDepthTexture[r.otherPx + int2(-kEdgeMargin, 0)],
SrcDepthTexture[r.otherPx + int2(kEdgeMargin, 0)],
SrcDepthTexture[r.otherPx + int2(0, -kEdgeMargin)],
SrcDepthTexture[r.otherPx + int2(0, kEdgeMargin)]);
if (any(otherNeighbors < 1e-5) || MaxDepthDiff(otherDepth, otherNeighbors) > kEdgeDepthThreshold) {
float4 otherNeighbors = SampleCrossDepths(r.otherPx, kEdgeMargin, 1 - eyeIndex);
if (any(otherNeighbors < 1e-5) || Stereo::MaxDepthDiff(otherDepth, otherNeighbors) > kEdgeDepthThreshold) {
OutShadowTexture[dtid] = myShadow;
return;
}
Expand Down
53 changes: 53 additions & 0 deletions package/Shaders/Common/VR.hlsli
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,59 @@ namespace Stereo
return normalizedCoord;
}

/**
* @brief Returns the maximum absolute depth difference between a center depth and four neighbors.
*
* Used for depth-discontinuity edge detection in stereo sync passes.
* Works with both NDC depths (fixed absolute threshold) and linear view-space depths
* (relative threshold: divide result by max(center, 1.0)).
*
* @param[in] center Depth at the pixel being tested.
* @param[in] neighbors Depths at four neighboring pixels (e.g. ±1 or ±2 cross pattern).
* @return Maximum of |center - neighbor| across all four samples.
*/
float MaxDepthDiff(float center, float4 neighbors)
{
return max(max(abs(center - neighbors.x), abs(center - neighbors.y)),
max(abs(center - neighbors.z), abs(center - neighbors.w)));
}

/**
* @brief Clamps a stereo UV coordinate to the eye-local X range of the packed stereo buffer.
*
* Prevents cross-neighbor UV samples from crossing the x=0.5 seam into the other eye's
* region of the side-by-side stereo texture. Y is not clamped; sampler address modes
* handle vertical out-of-bounds.
*
* @param[in] uv Stereo UV coordinate to clamp.
* @param[in] eyeIndex Eye index (0 = left [0, 0.5], 1 = right [0.5, 1]).
* @return UV with x restricted to eyeIndex's half of the stereo buffer.
*/
float2 ClampToEyeUV(float2 uv, uint eyeIndex)
{
uv.x = clamp(uv.x, eyeIndex == 0 ? 0.0f : 0.5f, eyeIndex == 0 ? 0.5f : 1.0f);
return uv;
}

/**
* @brief Clamps a pixel coordinate to the eye-local X bounds of the packed stereo buffer.
*
* Prevents cross-neighbor pixel reads from crossing the half-width seam into the
* other eye's region of the side-by-side stereo texture.
*
* @param[in] px Pixel coordinate to clamp.
* @param[in] eyeIndex Eye index (0 = left, 1 = right).
* @param[in] frameDim Full stereo buffer dimensions (width covers both eyes).
* @return Clamped pixel coordinate, restricted to eyeIndex's half of the buffer.
*/
int2 ClampToEyeBounds(int2 px, uint eyeIndex, float2 frameDim)
{
int halfWidth = (int)frameDim.x / 2;
px.x = clamp(px.x, eyeIndex == 0 ? 0 : halfWidth, eyeIndex == 0 ? (halfWidth - 1) : ((int)frameDim.x - 1));
px.y = clamp(px.y, 0, (int)frameDim.y - 1);
return px;
}

#if defined(PSHADER) || defined(FRAMEBUFFER)
// These functions require the framebuffer which is typically provided with the PSHADER
/**
Expand Down
156 changes: 156 additions & 0 deletions package/Shaders/Tests/TestVR.hlsl
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,162 @@ static const float kEps = 0.0001f;
ASSERT(AreEqual, Stereo::GetEyeIndexFromTexCoord(stereoRight), 1u);
}

/// @tags vr, stereo, depth, edge-detection
/// MaxDepthDiff: identical neighbors -> 0
[numthreads(1, 1, 1)] void TestMaxDepthDiffAllSame() {
float result = Stereo::MaxDepthDiff(0.5, float4(0.5, 0.5, 0.5, 0.5));
ASSERT(IsTrue, abs(result) < kEps);
}

/// @tags vr, stereo, depth, edge-detection
/// MaxDepthDiff: returns |center - neighbor| when one neighbor differs
[numthreads(1, 1, 1)] void TestMaxDepthDiffOneDiffers()
{
// Only .z differs
float result = Stereo::MaxDepthDiff(0.5, float4(0.5, 0.5, 0.8, 0.5));
ASSERT(IsTrue, abs(result - 0.3) < kEps);
}

/// @tags vr, stereo, depth, edge-detection
/// MaxDepthDiff: returns the largest difference across all four neighbors
[numthreads(1, 1, 1)] void TestMaxDepthDiffPicksLargest() {
float result = Stereo::MaxDepthDiff(0.5, float4(0.55, 0.45, 0.9, 0.48));
ASSERT(IsTrue, abs(result - 0.4) < kEps); // abs(0.5 - 0.9) = 0.4
}

/// @tags vr, stereo, depth, edge-detection
/// MaxDepthDiff: arm/world case returns exact diff (arm=0.75, world=1.0 -> 0.25)
[numthreads(1, 1, 1)] void TestMaxDepthDiffArmWorldCase()
{
float armDepth = 0.75;
float worldDepth = 1.0;
float result = Stereo::MaxDepthDiff(armDepth, float4(worldDepth, armDepth, armDepth, armDepth));
ASSERT(IsTrue, abs(result - abs(worldDepth - armDepth)) < kEps);
}

/// @tags vr, stereo, depth, edge-detection
/// MaxDepthDiff: symmetric - diff(a,b) == diff(b,a)
[numthreads(1, 1, 1)] void TestMaxDepthDiffSymmetry() {
float a = 0.3, b = 0.7;
float fwd = Stereo::MaxDepthDiff(a, float4(b, a, a, a));
float rev = Stereo::MaxDepthDiff(b, float4(a, b, b, b));
ASSERT(IsTrue, abs(fwd - rev) < kEps);
}

/// @tags vr, stereo, depth, edge-detection
/// MaxDepthDiff: center == 0 (mask pixel) against world neighbor
[numthreads(1, 1, 1)] void TestMaxDepthDiffMaskCenter()
{
float result = Stereo::MaxDepthDiff(0.0, float4(0.8, 0.0, 0.0, 0.0));
ASSERT(IsTrue, abs(result - 0.8) < kEps);
}

/// @tags vr, stereo, edge-detection
/// ClampToEyeBounds: interior pixel is returned unchanged for both eyes
[numthreads(1, 1, 1)] void TestClampToEyeBoundsInterior() {
float2 frameDim = float2(2048, 1024);
int2 left = Stereo::ClampToEyeBounds(int2(512, 512), 0, frameDim);
ASSERT(AreEqual, left.x, 512);
ASSERT(AreEqual, left.y, 512);

int2 right = Stereo::ClampToEyeBounds(int2(1536, 512), 1, frameDim);
ASSERT(AreEqual, right.x, 1536);
ASSERT(AreEqual, right.y, 512);
}

/// @tags vr, stereo, edge-detection
/// ClampToEyeBounds: left eye x cannot cross the half-width seam
[numthreads(1, 1, 1)] void TestClampToEyeBoundsLeftEyeSeam()
{
float2 frameDim = float2(2048, 1024);
// x past the seam clamps to halfWidth - 1 = 1023
int2 result = Stereo::ClampToEyeBounds(int2(1025, 512), 0, frameDim);
ASSERT(AreEqual, result.x, 1023);
}

/// @tags vr, stereo, edge-detection
/// ClampToEyeBounds: right eye x cannot cross the half-width seam
[numthreads(1, 1, 1)] void TestClampToEyeBoundsRightEyeSeam() {
float2 frameDim = float2(2048, 1024);
// x before the seam clamps to halfWidth = 1024
int2 result = Stereo::ClampToEyeBounds(int2(1022, 512), 1, frameDim);
ASSERT(AreEqual, result.x, 1024);
}

/// @tags vr, stereo, edge-detection
/// ClampToEyeBounds: x clamped at outer borders (left eye left edge, right eye right edge)
[numthreads(1, 1, 1)] void TestClampToEyeBoundsOuterBorders()
{
float2 frameDim = float2(2048, 1024);
int2 leftBorder = Stereo::ClampToEyeBounds(int2(-1, 512), 0, frameDim);
ASSERT(AreEqual, leftBorder.x, 0);

int2 rightBorder = Stereo::ClampToEyeBounds(int2(2049, 512), 1, frameDim);
ASSERT(AreEqual, rightBorder.x, 2047);
}

/// @tags vr, stereo, edge-detection
/// ClampToEyeBounds: y is clamped to [0, frameDim.y - 1] independently of eye
[numthreads(1, 1, 1)] void TestClampToEyeBoundsY() {
float2 frameDim = float2(2048, 1024);
int2 top = Stereo::ClampToEyeBounds(int2(512, -1), 0, frameDim);
ASSERT(AreEqual, top.y, 0);

int2 bottom = Stereo::ClampToEyeBounds(int2(512, 1025), 0, frameDim);
ASSERT(AreEqual, bottom.y, 1023);
}

/// @tags vr, stereo, edge-detection
/// ClampToEyeUV: interior UV is returned unchanged for both eyes
[numthreads(1, 1, 1)] void TestClampToEyeUVInterior()
{
float2 left = Stereo::ClampToEyeUV(float2(0.25, 0.5), 0);
ASSERT(IsTrue, abs(left.x - 0.25) < kEps);
ASSERT(IsTrue, abs(left.y - 0.5) < kEps);

float2 right = Stereo::ClampToEyeUV(float2(0.75, 0.5), 1);
ASSERT(IsTrue, abs(right.x - 0.75) < kEps);
ASSERT(IsTrue, abs(right.y - 0.5) < kEps);
}

/// @tags vr, stereo, edge-detection
/// ClampToEyeUV: left eye x cannot cross the x=0.5 seam
[numthreads(1, 1, 1)] void TestClampToEyeUVLeftEyeSeam() {
// x past the seam clamps to 0.5
float2 result = Stereo::ClampToEyeUV(float2(0.6, 0.5), 0);
ASSERT(IsTrue, abs(result.x - 0.5) < kEps);
}

/// @tags vr, stereo, edge-detection
/// ClampToEyeUV: right eye x cannot cross the x=0.5 seam
[numthreads(1, 1, 1)] void TestClampToEyeUVRightEyeSeam()
{
// x before the seam clamps to 0.5
float2 result = Stereo::ClampToEyeUV(float2(0.4, 0.5), 1);
ASSERT(IsTrue, abs(result.x - 0.5) < kEps);
}

/// @tags vr, stereo, edge-detection
/// ClampToEyeUV: x clamped at outer borders (left eye at 0.0, right eye at 1.0)
[numthreads(1, 1, 1)] void TestClampToEyeUVOuterBorders() {
float2 leftBorder = Stereo::ClampToEyeUV(float2(-0.1, 0.5), 0);
ASSERT(IsTrue, abs(leftBorder.x - 0.0) < kEps);

float2 rightBorder = Stereo::ClampToEyeUV(float2(1.1, 0.5), 1);
ASSERT(IsTrue, abs(rightBorder.x - 1.0) < kEps);
}

/// @tags vr, stereo, edge-detection
/// ClampToEyeUV: y coordinate is not modified
[numthreads(1, 1, 1)] void TestClampToEyeUVYUnchanged()
{
float2 result = Stereo::ClampToEyeUV(float2(0.25, 1.5), 0);
ASSERT(IsTrue, abs(result.y - 1.5) < kEps);

result = Stereo::ClampToEyeUV(float2(0.75, -0.5), 1);
ASSERT(IsTrue, abs(result.y - (-0.5)) < kEps);
}

/// @tags vr, stereo, uv
/// ConvertToStereoUV clamps input x to [0,1] via saturate
[numthreads(1, 1, 1)] void TestConvertToStereoUVClamping() {
Expand Down
Loading