From a8500a80b5e30b8703899a7a00b23e04c3a12f55 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 8 Mar 2026 15:15:44 -0700 Subject: [PATCH] FSR2: selective clamp, tonemapped accumulation, terrain load radius 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Selective neighborhood clamp: only modify history when there's actual motion or disocclusion — static pixels pass history through untouched, preventing jitter-chasing from the shifting variance box - Tonemapped accumulation: Reinhard tonemap before blend compresses bright edges so they don't disproportionately cause jitter - Jitter-aware sample weighting: blend 3-20% based on sample proximity - Soft MV dead zone: smoothstep instead of step avoids spatial discontinuity - Aggressive velocity response: 30%/px motion, 50% cap, 80% disocclusion - Terrain loading: radius 3 (49 tiles) to prevent spawn hitches, processOneReadyTile for smooth progress bar updates --- assets/shaders/fsr2_accumulate.comp.glsl | 107 +++++++++++++---------- assets/shaders/fsr2_accumulate.comp.spv | Bin 18288 -> 20832 bytes assets/shaders/fsr2_motion.comp.glsl | 8 +- assets/shaders/fsr2_motion.comp.spv | Bin 3728 -> 3732 bytes src/core/application.cpp | 2 +- 5 files changed, 65 insertions(+), 52 deletions(-) diff --git a/assets/shaders/fsr2_accumulate.comp.glsl b/assets/shaders/fsr2_accumulate.comp.glsl index 756945f0..bcaad6f8 100644 --- a/assets/shaders/fsr2_accumulate.comp.glsl +++ b/assets/shaders/fsr2_accumulate.comp.glsl @@ -15,6 +15,16 @@ layout(push_constant) uniform PushConstants { vec4 params; // x = resetHistory (1=reset), y = sharpness, zw = unused } pc; +vec3 tonemap(vec3 c) { + float luma = max(dot(c, vec3(0.299, 0.587, 0.114)), 0.0); + return c / (1.0 + luma); +} + +vec3 inverseTonemap(vec3 c) { + float luma = max(dot(c, vec3(0.299, 0.587, 0.114)), 0.0); + return c / max(1.0 - luma, 1e-4); +} + vec3 rgbToYCoCg(vec3 rgb) { float y = 0.25 * rgb.r + 0.5 * rgb.g + 0.25 * rgb.b; float co = 0.5 * rgb.r - 0.5 * rgb.b; @@ -30,14 +40,12 @@ vec3 yCoCgToRgb(vec3 ycocg) { } // Catmull-Rom bicubic (9 bilinear taps) with anti-ringing clamp. -// Sharper than bilinear; anti-ringing prevents edge halos that shift with jitter. vec3 sampleBicubic(sampler2D tex, vec2 uv, vec2 texSize) { vec2 invTexSize = 1.0 / texSize; vec2 iTc = uv * texSize; vec2 tc = floor(iTc - 0.5) + 0.5; vec2 f = iTc - tc; - // Catmull-Rom weights vec2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f)); vec2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f); vec2 w2 = f * (0.5 + f * (2.0 - 1.5 * f)); @@ -50,7 +58,6 @@ vec3 sampleBicubic(sampler2D tex, vec2 uv, vec2 texSize) { vec2 tc3 = (tc + 2.0) * invTexSize; vec2 tc12 = (tc + offset12) * invTexSize; - // 3x3 bilinear taps covering 4x4 texel support vec3 result = (texture(tex, vec2(tc0.x, tc0.y)).rgb * w0.x + texture(tex, vec2(tc12.x, tc0.y)).rgb * s12.x + @@ -62,8 +69,7 @@ vec3 sampleBicubic(sampler2D tex, vec2 uv, vec2 texSize) { texture(tex, vec2(tc12.x, tc3.y)).rgb * s12.x + texture(tex, vec2(tc3.x, tc3.y)).rgb * w3.x) * w3.y; - // Anti-ringing: clamp to range of the 4 nearest texels. - // Prevents Catmull-Rom negative lobe overshoots at high-contrast edges. + // Anti-ringing: clamp to range of the 4 nearest texels vec2 tcNear = tc * invTexSize; vec3 t00 = texture(tex, tcNear).rgb; vec3 t10 = texture(tex, tcNear + vec2(invTexSize.x, 0.0)).rgb; @@ -81,24 +87,14 @@ void main() { vec2 outUV = (vec2(outPixel) + 0.5) * pc.displaySize.zw; - // De-jitter: the scene was rendered with sub-pixel jitter, effectively - // shifting the internal image by jitterUV. Sampling at (outUV - jitterUV) - // undoes this shift, reconstructing the scene at the output pixel's true - // unjittered position. This makes the sampled value consistent across - // frames, eliminating the primary source of temporal jitter. - vec2 jitterUV = pc.jitterOffset.xy * 0.5; - vec2 dejitteredUV = outUV - jitterUV; - - vec3 currentColor = sampleBicubic(sceneColor, dejitteredUV, pc.internalSize.xy); + vec3 currentColor = sampleBicubic(sceneColor, outUV, pc.internalSize.xy); if (pc.params.x > 0.5) { imageStore(historyOutput, outPixel, vec4(currentColor, 1.0)); return; } - // Depth-dilated motion vector: pick the MV from the nearest-to-camera - // pixel in a 3x3 neighborhood. Prevents background MVs from bleeding - // over foreground edges. + // Depth-dilated motion vector (3x3 nearest-to-camera) vec2 texelSize = pc.internalSize.zw; float closestDepth = texture(depthBuffer, outUV).r; vec2 closestOffset = vec2(0.0); @@ -113,26 +109,27 @@ void main() { } } vec2 motion = texture(motionVectors, outUV + closestOffset).rg; + float motionMag = length(motion * pc.displaySize.xy); vec2 historyUV = outUV + motion; - float historyValid = (historyUV.x >= 0.0 && historyUV.x <= 1.0 && historyUV.y >= 0.0 && historyUV.y <= 1.0) ? 1.0 : 0.0; - vec3 historyColor = texture(historyInput, historyUV).rgb; - // Neighborhood clamping in YCoCg space at de-jittered positions. - // De-jittered neighborhood is stable across frames, preventing - // the clamp box from chasing jitter. - vec3 s0 = rgbToYCoCg(currentColor); - vec3 s1 = rgbToYCoCg(texture(sceneColor, dejitteredUV + vec2(-texelSize.x, 0.0)).rgb); - vec3 s2 = rgbToYCoCg(texture(sceneColor, dejitteredUV + vec2( texelSize.x, 0.0)).rgb); - vec3 s3 = rgbToYCoCg(texture(sceneColor, dejitteredUV + vec2(0.0, -texelSize.y)).rgb); - vec3 s4 = rgbToYCoCg(texture(sceneColor, dejitteredUV + vec2(0.0, texelSize.y)).rgb); - vec3 s5 = rgbToYCoCg(texture(sceneColor, dejitteredUV + vec2(-texelSize.x, -texelSize.y)).rgb); - vec3 s6 = rgbToYCoCg(texture(sceneColor, dejitteredUV + vec2( texelSize.x, -texelSize.y)).rgb); - vec3 s7 = rgbToYCoCg(texture(sceneColor, dejitteredUV + vec2(-texelSize.x, texelSize.y)).rgb); - vec3 s8 = rgbToYCoCg(texture(sceneColor, dejitteredUV + vec2( texelSize.x, texelSize.y)).rgb); + // === Tonemapped accumulation === + vec3 tmCurrent = tonemap(currentColor); + vec3 tmHistory = tonemap(historyColor); + + // Neighborhood in tonemapped YCoCg + vec3 s0 = rgbToYCoCg(tmCurrent); + vec3 s1 = rgbToYCoCg(tonemap(texture(sceneColor, outUV + vec2(-texelSize.x, 0.0)).rgb)); + vec3 s2 = rgbToYCoCg(tonemap(texture(sceneColor, outUV + vec2( texelSize.x, 0.0)).rgb)); + vec3 s3 = rgbToYCoCg(tonemap(texture(sceneColor, outUV + vec2(0.0, -texelSize.y)).rgb)); + vec3 s4 = rgbToYCoCg(tonemap(texture(sceneColor, outUV + vec2(0.0, texelSize.y)).rgb)); + vec3 s5 = rgbToYCoCg(tonemap(texture(sceneColor, outUV + vec2(-texelSize.x, -texelSize.y)).rgb)); + vec3 s6 = rgbToYCoCg(tonemap(texture(sceneColor, outUV + vec2( texelSize.x, -texelSize.y)).rgb)); + vec3 s7 = rgbToYCoCg(tonemap(texture(sceneColor, outUV + vec2(-texelSize.x, texelSize.y)).rgb)); + vec3 s8 = rgbToYCoCg(tonemap(texture(sceneColor, outUV + vec2( texelSize.x, texelSize.y)).rgb)); vec3 m1 = s0 + s1 + s2 + s3 + s4 + s5 + s6 + s7 + s8; vec3 m2 = s0*s0 + s1*s1 + s2*s2 + s3*s3 + s4*s4 + s5*s5 + s6*s6 + s7*s7 + s8*s8; @@ -140,31 +137,49 @@ void main() { vec3 variance = max(m2 / 9.0 - mean * mean, vec3(0.0)); vec3 stddev = sqrt(variance); - float gamma = 2.0; + float gamma = 1.5; vec3 boxMin = mean - gamma * stddev; vec3 boxMax = mean + gamma * stddev; - vec3 historyYCoCg = rgbToYCoCg(historyColor); - vec3 clampedHistory = clamp(historyYCoCg, boxMin, boxMax); - historyColor = yCoCgToRgb(clampedHistory); + // Compute clamped history and measure how far it was from the box + vec3 tmHistYCoCg = rgbToYCoCg(tmHistory); + vec3 clampedYCoCg = clamp(tmHistYCoCg, boxMin, boxMax); + float clampDist = length(tmHistYCoCg - clampedYCoCg); - float clampDist = length(historyYCoCg - clampedHistory); + // SELECTIVE CLAMP: only modify history when there's motion or disocclusion. + // For static pixels, history is already well-accumulated — clamping it + // each frame causes the clamp box (which shifts with jitter) to drag + // the history around, creating visible shimmer. By leaving static history + // untouched, accumulated anti-aliasing and detail is preserved. + float needsClamp = max( + clamp(motionMag * 2.0, 0.0, 1.0), // motion → full clamp + clamp(clampDist * 3.0, 0.0, 1.0) // disocclusion → full clamp + ); + tmHistory = yCoCgToRgb(mix(tmHistYCoCg, clampedYCoCg, needsClamp)); - // With de-jittered sampling, the reconstructed value is consistent across - // frames, so a uniform blend rate works without causing visible jitter. - // 8% gives ~28 frames for 90% convergence (~0.5s at 60fps). - float blendFactor = 0.08; + // Blend: higher for good jitter samples, lower for poor ones. + // Jitter-aware weighting: current frame's sample quality depends on + // how close the jittered sample fell to this output pixel. + vec2 jitterPx = pc.jitterOffset.xy * 0.5 * pc.internalSize.xy; + vec2 internalPos = outUV * pc.internalSize.xy; + vec2 subPixelOffset = fract(internalPos) - 0.5; + vec2 sampleDelta = subPixelOffset - jitterPx; + float dist2 = dot(sampleDelta, sampleDelta); + float sampleQuality = exp(-dist2 * 3.0); + float blendFactor = mix(0.03, 0.20, sampleQuality); - // Disocclusion: large clamp distance → rapidly replace stale history - blendFactor = mix(blendFactor, 0.60, clamp(clampDist * 5.0, 0.0, 1.0)); + // Disocclusion: aggressively replace stale history + blendFactor = mix(blendFactor, 0.80, clamp(clampDist * 5.0, 0.0, 1.0)); - // Velocity: higher blend during motion reduces ghosting - float motionMag = length(motion * pc.displaySize.xy); - blendFactor = max(blendFactor, clamp(motionMag * 0.15, 0.0, 0.35)); + // Velocity: strong response during camera/object motion + blendFactor = max(blendFactor, clamp(motionMag * 0.30, 0.0, 0.50)); // Full current frame when history is out of bounds blendFactor = mix(blendFactor, 1.0, 1.0 - historyValid); - vec3 result = mix(historyColor, currentColor, blendFactor); + // Blend in tonemapped space, inverse-tonemap back to linear + vec3 tmResult = mix(tmHistory, tmCurrent, blendFactor); + vec3 result = inverseTonemap(tmResult); + imageStore(historyOutput, outPixel, vec4(result, 1.0)); } diff --git a/assets/shaders/fsr2_accumulate.comp.spv b/assets/shaders/fsr2_accumulate.comp.spv index 2ea3acb515e67eced0532dde96d59d0168498a15..c45903790450995759e02f11421bd1271fcff2ed 100644 GIT binary patch literal 20832 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 literal 18288 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 Xe@@4N`KkV&!m;}6ItktD0-&P`5Wg3qGcZ6 z*w62-fICwgs39t1G+cB)yN#N{DQIcf0*ck EKSfX|vH$=8 delta 300 zcmXYrJqp555QI00F+?OFVxbZ+1}huEM)0Q-YfEbnpq3V5<6n#>YP^MwkaJjj8o`-I z7Cv@o_wDR^x;OW|)U_~K>8TfrNE0w7k`rnVXi6IrrekBF+l8+`sERE?@-h9^8AC<@ z`dFgoBBKpmMvhZ6|FX7wlp9bKOSKQE>D>W4!1)j>HRK~$gGfxJPg##UBQeyBJ-+AZ u3*cF53;7D{Ow%tCNxDv{f~;Z+Uw-WlqGaaO8!;*}hXdW{@Blyju?PMHmnEsetLoadRadius(2); // 5x5=25 tiles — balance between spawn hitches and load time + terrainMgr->setLoadRadius(3); // 7x7=49 tiles — prevents hitches on spawn terrainMgr->setUnloadRadius(7); // Trigger tile streaming for surrounding area