diff --git a/src/dxvk/rtx_render/rtx_auto_exposure.cpp b/src/dxvk/rtx_render/rtx_auto_exposure.cpp index f073c84fd..e12cca1d1 100644 --- a/src/dxvk/rtx_render/rtx_auto_exposure.cpp +++ b/src/dxvk/rtx_render/rtx_auto_exposure.cpp @@ -77,6 +77,8 @@ namespace dxvk { ImGui::Indent(); RemixGui::DragFloat("Light Adapt Tau (s)", &lightAdaptTauObject(), 0.005f, 0.01f, 5.f, "%.3f", ImGuiSliderFlags_AlwaysClamp); RemixGui::DragFloat("Dark Adapt Tau (s)", &darkAdaptTauObject(), 0.01f, 0.05f, 10.f, "%.3f", ImGuiSliderFlags_AlwaysClamp); + RemixGui::DragFloat("Target Mid-Gray (Yf)", &targetAdaptedYfObject(), 0.001f, 0.01f, 2.0f, "%.3f", ImGuiSliderFlags_AlwaysClamp); + RemixGui::DragFloat("Max Exposure (x)", &maxExposureObject(), 0.1f, 1.0f, 64.0f, "%.2fx", ImGuiSliderFlags_AlwaysClamp); RemixGui::Separator(); ImGui::Unindent(); } @@ -157,8 +159,10 @@ namespace dxvk { const float effectiveFrameTimeMs = frameTimeMilliseconds > 0.0f ? frameTimeMilliseconds : fallbackMs; pushArgs.deltaTime = effectiveFrameTimeMs * 0.001f; } - pushArgs.lightAdaptTau = lightAdaptTau(); - pushArgs.darkAdaptTau = darkAdaptTau(); + pushArgs.lightAdaptTau = lightAdaptTau(); + pushArgs.darkAdaptTau = darkAdaptTau(); + pushArgs.targetAdaptedYf = targetAdaptedYf(); + pushArgs.maxExposure = maxExposure(); pushArgs.debugMode = (ctx->getCommonObjects()->metaDebugView().debugViewIdx() == DEBUG_VIEW_EXPOSURE_HISTOGRAM); { diff --git a/src/dxvk/rtx_render/rtx_auto_exposure.h b/src/dxvk/rtx_render/rtx_auto_exposure.h index 9813334ce..e74d30a06 100644 --- a/src/dxvk/rtx_render/rtx_auto_exposure.h +++ b/src/dxvk/rtx_render/rtx_auto_exposure.h @@ -103,6 +103,15 @@ namespace dxvk { "Scotopic (dark) adaptation time constant in seconds. " "Controls how slowly the eye brightens up when the scene dims. " "Larger = slower response. Typical range: 0.25 – 3.00 s."); + RTX_OPTION("rtx.autoExposure", float, targetAdaptedYf, 0.18f, + "Adaptation target Yf (mid-gray reflectance, default 0.18). " + "After exposure is applied the scene's geometric-mean Yf lands here. " + "Raise above 0.18 to bias the image brighter; lower to darken it."); + RTX_OPTION("rtx.autoExposure", float, maxExposure, 8.0f, + "Maximum auto-exposure multiplier. Caps how much the algorithm is allowed " + "to brighten the image when looking at something dark (1.0 = no brightening, " + "8.0 = up to 8x brighter, ~56 = uncapped default behaviour). Lower this if " + "shadows / dark rooms get blown out into mid-gray."); }; } diff --git a/src/dxvk/rtx_render/rtx_fork_tonemap.h b/src/dxvk/rtx_render/rtx_fork_tonemap.h index 2bce2fa9c..8c1b48fe7 100644 --- a/src/dxvk/rtx_render/rtx_fork_tonemap.h +++ b/src/dxvk/rtx_render/rtx_fork_tonemap.h @@ -37,7 +37,7 @@ namespace dxvk { // 2026-05-13 / 2026-05-15 refactors and the apply pass now dispatches // directly to the operator selected here. class RtxForkGlobalTonemap { - RTX_OPTION_ENV("rtx.tonemap", TonemapOperator, tonemapOperator, TonemapOperator::None, "DXVK_TONEMAP_OPERATOR", + RTX_OPTION_ENV("rtx.tonemap", TonemapOperator, tonemapOperator, TonemapOperator::Psycho17, "DXVK_TONEMAP_OPERATOR", "Tonemapping operator applied to the post-exposure color buffer.\n" "Supported values: 0 = None (saturate-only identity), 1 = Hill ACES, 2 = Narkowicz ACES, " "3 = Hable Filmic, 4 = AgX, 5 = Lottes 2016, 6 = PsychoV17_Beta, " diff --git a/src/dxvk/shaders/rtx/pass/tonemap/auto_exposure.comp.slang b/src/dxvk/shaders/rtx/pass/tonemap/auto_exposure.comp.slang index c0d0f30f7..8b25a092c 100644 --- a/src/dxvk/shaders/rtx/pass/tonemap/auto_exposure.comp.slang +++ b/src/dxvk/shaders/rtx/pass/tonemap/auto_exposure.comp.slang @@ -50,9 +50,9 @@ groupshared float g_count[EXPOSURE_HISTOGRAM_SIZE]; // - Spatial pooling for adaptation is geometric, not arithmetic. The // neural response to luminance is approximately logarithmic // (Stockman & Brainard 2010), so a log-mean of Yf is the appropriate -// first-moment statistic. We retain the Gaussian per-bin weighting -// the prior shader used (centred on mid-gray in log-Yf space) so -// adaptation tracks the mid-tone subject the eye actually fixates +// first-moment statistic. Center-weighted metering (a spatial +// Gaussian on pixel position applied in the histogram pass) biases +// bin counts toward the mid-tone subject the eye actually fixates // on rather than the unweighted scene mean. // - The "what exposure scale do I apply" decision is a separate // question from the tonemap shoulder/toe shape. The previous shader @@ -67,55 +67,31 @@ groupshared float g_count[EXPOSURE_HISTOGRAM_SIZE]; // it. No basis change between pipeline stages. // --------------------------------------------------------------------------- -// Adaptation target: mid-gray reflectance (~18%). After the exposure -// scale is applied, the scene's geometric-mean Yf lands here, which is -// the diffuse-gray adapted state psycho17 assumes for its default -// adaptive_state_bt709 = (0.18, 0.18, 0.18). -static const float kTargetAdaptedYf = 0.18f; - // Stockman-Brainard first-site cone-noise floor. Caps the dark-scene // exposure boost so a near-black frame doesn't blow up to infinity: -// exposure = kTargetAdaptedYf / (Y_adapt + Y_noise) -// With Y_noise = 0.0032 (~mid-mesopic), max exposure ~= 56x. Reference: -// Stockman & Brainard (2010), "Color Vision Mechanisms". +// exposure = targetAdaptedYf / (Y_adapt + Y_noise) +// With Y_noise = 0.0032 (~mid-mesopic), max boost ~= targetAdaptedYf/0.0032. +// Reference: Stockman & Brainard (2010), "Color Vision Mechanisms". static const float kConeNoiseFloorYf = 0.0032f; -// Floor for the geometric-mean Yf so log/exp never see zero when the -// histogram is empty or near-black-only. +// Secondary floor for the adapted Yf. The primary dark-scene guard is +// kConeNoiseFloorYf (0.0032), which dominates at all realistic adaptation +// levels — this only activates if exp2(sumLogYf / totalWeight) underflows +// below 1e-6 (e.g. heavily clipped histogram). static const float kAdaptedYfFloor = 1e-6f; -// Gaussian weighting across log-Yf bins, centred on the mid-gray -// adaptation target. The eye does not adapt to the unweighted scene -// mean — overblown skies and crushed shadows are heavily discounted -// when the gaze (and the cone population responding to it) is on the -// mid-tone subject. A Gaussian in log-Yf space matches this falloff -// closely and is the same pooling the prior Naka-Rushton resolve and -// the reference RDR2 / renodx implementations use. -// -// w_i = exp( -((log2(Yf_i) - log2(Y_target))^2) / (2 * sigma^2) ) -// -// sigma is in stops (log2 units). 2.0 stops keeps ~95% of the -// weighting mass inside +/-4 stops of mid-gray, which matches typical -// outdoor / indoor dynamic ranges without letting a small bright -// window pin adaptation to the sky. -static const float kGaussianSigmaStops = 2.0f; -static const float kGaussianLogCenter = -2.4739311883324f; // log2(0.18) -static const float kGaussianInvTwoSigmaSq = 1.0f / (2.0f * kGaussianSigmaStops * kGaussianSigmaStops); - [shader("compute")] [numthreads(EXPOSURE_HISTOGRAM_SIZE, 1, 1)] void main(uint2 threadId : SV_DispatchThreadID, uint linearIndex : SV_GroupIndex) { - // Per-bin contribution to a Gaussian-weighted log-Yf geometric mean. - // w_i = exp( -(log2(Yf_i) - log2(Y_target))^2 / (2*sigma^2) ) - // sum_wLogYf = sum_i ( w_i * count_i * log2(Yf_i) ) - // sum_w = sum_i ( w_i * count_i ) + // Per-bin contribution to a center-weighted log-Yf geometric mean. + // Spatial Gaussian center-weighting is applied in the histogram pass, + // so bin counts already reflect that weighting. Each bin contributes + // its center Yf scaled by its (spatially-weighted) pixel count. + // sum_wLogYf = sum_i ( count_i * log2(Yf_i) ) + // sum_w = sum_i ( count_i ) // geomMean = exp2( sum_wLogYf / sum_w ) - // Bin 0 is reserved for near-black pixels and excluded — they would - // pin the geometric mean at the histogram floor regardless of the - // rest of the scene. The Gaussian softly down-weights the remaining - // tail bins (overblown highlights, deep shadows) so adaptation - // tracks the mid-tone subject the eye is actually looking at. + // Bin 0 is reserved for near-black pixels and excluded. const uint pixelCount = InOutHistogram[linearIndex]; float sumWeightedLogYf = 0.0f; float sumWeight = 0.0f; @@ -123,22 +99,16 @@ void main(uint2 threadId : SV_DispatchThreadID, uint linearIndex : SV_GroupIndex { const float binYf = histogramBinToLuminance(linearIndex); const float logBinYf = log2(binYf); - const float dLog = logBinYf - kGaussianLogCenter; - const float weight = exp(-dLog * dLog * kGaussianInvTwoSigmaSq); - const float wCount = weight * float(pixelCount); - sumWeightedLogYf = wCount * logBinYf; - sumWeight = wCount; + sumWeightedLogYf = float(pixelCount) * logBinYf; + sumWeight = float(pixelCount); } g_sumLogYf[linearIndex] = sumWeightedLogYf; g_count[linearIndex] = sumWeight; GroupMemoryBarrierWithGroupSync(); // Parallel tree reduction across all EXPOSURE_HISTOGRAM_SIZE (power-of-two) - // lanes. Bin 0 is excluded by virtue of the contribution stage above - // having written 0 into g_sumLogYf[0] / g_count[0] (the `linearIndex >= 1` - // guard fails on lane 0), so summing the full array drops it implicitly. - // Replaces the previous single-thread serial sum which parked the other - // 255 lanes idle every frame. + // lanes. Bin 0 is excluded by the `linearIndex >= 1` guard above, so + // summing the full array drops it implicitly. [unroll] for (uint stride = EXPOSURE_HISTOGRAM_SIZE / 2; stride > 0; stride >>= 1) { @@ -155,26 +125,20 @@ void main(uint2 threadId : SV_DispatchThreadID, uint linearIndex : SV_GroupIndex const float totalSumLogYf = g_sumLogYf[0]; const float totalWeight = g_count[0]; - // Gaussian-weighted geometric mean of Yf. This is the adapted scene - // level the cone system would settle on for static viewing, biased - // toward the mid-tone population the gaze fixates on. + // Center-weighted geometric mean of Yf. The spatial Gaussian from + // the histogram pass already biases counts toward the mid-tone + // subject at the screen centre; this reduces to a standard + // count-weighted log mean. const float adaptedYf = (totalWeight > 0.0f) ? max(exp2(totalSumLogYf / totalWeight), kAdaptedYfFloor) - : kTargetAdaptedYf; // Empty histogram => neutral exposure. - - // First-site cone-contrast law (Stockman & Brainard 2010, eq. 3.1): - // R(Y) = Y / (Y + Y0) - // We want the exposure scale s such that the adapted response lands - // at the response of the target mid-gray with the same noise floor: - // s * Y_adapt / (s * Y_adapt + Y0) == Y_target / (Y_target + Y0) - // Approximating (s * Y_adapt + Y0) ~ Y_target + Y0 in the operating - // regime gives the classic compact form - // s = Y_target / (Y_adapt + Y0) - // which agrees with the exact solution near the operating point and - // saturates gracefully for dark scenes (Y_adapt -> 0 => s capped by - // Y_target / Y0) and for bright scenes (Y_adapt >> Y_target => s - // falls off as Y_target / Y_adapt, classic inverse-luminance AE). - const float targetExposure = kTargetAdaptedYf / (adaptedYf + kConeNoiseFloorYf); + : cb.targetAdaptedYf; // Empty histogram => neutral exposure. + + const float rawTargetExposure = cb.targetAdaptedYf / (adaptedYf + kConeNoiseFloorYf); + + // Clamp the target before the temporal blend, not after. Clamping the + // smoothed output would let the integrator wind up against the cap and + // produce a sluggish recovery when the scene brightens again. + const float targetExposure = min(rawTargetExposure, cb.maxExposure); // Asymmetric exponential adaptation in log-exposure space. Human // eyes adapt faster to bright scenes (cone bleaching) than to dark @@ -182,12 +146,12 @@ void main(uint2 threadId : SV_DispatchThreadID, uint linearIndex : SV_GroupIndex // change. Log-space blending makes the time-constant invariant to // absolute scene level — a 4-stop change adapts in the same wall // time whether it happens at noon or twilight. - const float prevExposure = max(OutExposure[0], 1e-6f); - const float logPrev = log2(prevExposure); - const float logTarget = log2(max(targetExposure, 1e-6f)); + const float prevExposure = max(OutExposure[0], 1e-6f); + const float logPrev = log2(prevExposure); + const float logTarget = log2(max(targetExposure, 1e-6f)); const bool sceneBrightened = (targetExposure < prevExposure); - const float tau = sceneBrightened ? cb.lightAdaptTau : cb.darkAdaptTau; - const float logNew = adaptation::v1::ExponentialBlend( + const float tau = sceneBrightened ? cb.lightAdaptTau : cb.darkAdaptTau; + const float logNew = adaptation::v1::ExponentialBlend( logPrev, logTarget, cb.deltaTime, tau); OutExposure[0] = max(exp2(logNew), 1e-6f); @@ -195,12 +159,11 @@ void main(uint2 threadId : SV_DispatchThreadID, uint linearIndex : SV_GroupIndex if (cb.debugMode) { - // Debug view: raw histogram counts normalised (R) and per-bin Yf - // in [0, 1] reflectance (B), so the resolved adaptation level can - // be eyeballed in the debug pass. + // Debug view: spatially-weighted bin counts normalised by total pixel + // count (R) and per-bin Yf in [0, 1] reflectance (B). const float binYf = (linearIndex >= 1) ? histogramBinToLuminance(linearIndex) : 0.0f; DebugView[uint2(linearIndex, 0)] = vec4( - float(pixelCount) / 10000.0f, + float(pixelCount) / float(cb.numPixels), 0.0f, saturate(binYf), 1.0f); diff --git a/src/dxvk/shaders/rtx/pass/tonemap/auto_exposure_histogram.comp.slang b/src/dxvk/shaders/rtx/pass/tonemap/auto_exposure_histogram.comp.slang index 5c132d790..ce1626e57 100644 --- a/src/dxvk/shaders/rtx/pass/tonemap/auto_exposure_histogram.comp.slang +++ b/src/dxvk/shaders/rtx/pass/tonemap/auto_exposure_histogram.comp.slang @@ -48,7 +48,7 @@ groupshared uint g_localData[EXPOSURE_HISTOGRAM_SIZE]; // them for Yf is correct. uint inputToHistogramBucket(const float3 inputColor) { - const float yf = renodx::tonemap::psycho::yf::from_BT709(inputColor); + const float yf = renodx::tonemap::psycho::yf::from_BT709(inputColor) / renodx::tonemap::psycho::yf::from_BT709(1.0); // Negative or NaN inputs (e.g. denoiser ringing) collapse to bin 0 // via the !(yf >= eps) test so they are excluded from the resolve, @@ -77,7 +77,34 @@ void main(uint2 threadId : SV_DispatchThreadID, uint linearIndex : SV_GroupIndex { const float3 inputColor = InColorBuffer[threadId].xyz; const uint bucketIdx = inputToHistogramBucket(inputColor); - InterlockedAdd(g_localData[bucketIdx], 1); + + // Gaussian center-weighted metering. Pixels near the screen + // centre contribute more to the auto-exposure measurement than + // pixels at the edges — matching the "center-weighted" metering + // pattern of real cameras and giving stable, intuitive + // exposure when the player is looking around a scene with + // bright sky / dark indoors at the periphery. + // + // Distances are normalised by the SHORTER screen axis (height + // on widescreen), so the Gaussian is circular in pixel space + // and the horizontal periphery on a 16:9 / 21:9 frame falls + // off properly instead of contributing the same as the vertical + // periphery. At sigma = 0.25 of the short axis: + // * vertical edge midpoint (|dy| = 0.5) -> exp(-2) ~= 0.135 + // * horizontal edge midpoint on 16:9 -> exp(-6.3)~= 0.002 + // * horizontal edge midpoint on 21:9 -> exp(-12) ~= 6e-6 + const float invScale = 1.0f / float(min(dimensions.x, dimensions.y)); + const float2 delta = (float2(threadId) + 0.5f - 0.5f * float2(dimensions)) * invScale; + const float rSq = dot(delta, delta); + const float kSigma = 0.25f; + const float gauss = exp(-0.5f * rSq / (kSigma * kSigma)); + + // Fixed-point weight (scale 256). The resolve pass divides + // sum(weight * logYf) by sum(weight) so the absolute scale cancels. + // Any pixel that contributes at all gets at least 1 count, so + // the dimmest periphery still nudges the histogram. + const uint weight = max(uint(gauss * 256.0f + 0.5f), 1u); + InterlockedAdd(g_localData[bucketIdx], weight); } GroupMemoryBarrierWithGroupSync(); diff --git a/src/dxvk/shaders/rtx/pass/tonemap/fork_tonemap_operators.slangh b/src/dxvk/shaders/rtx/pass/tonemap/fork_tonemap_operators.slangh index dd5b26bde..58edaa650 100644 --- a/src/dxvk/shaders/rtx/pass/tonemap/fork_tonemap_operators.slangh +++ b/src/dxvk/shaders/rtx/pass/tonemap/fork_tonemap_operators.slangh @@ -86,13 +86,6 @@ float3 applyTonemapOperator(uint op, float3 color, bool suppressBlackLevelClamp, } if (op == tonemapOperatorPsycho17) { - // SDR mode: peak luminance pinned at 1.0. The cb-supplied - // psycho17PeakValue is also hardcoded to 1.0 in writeOperatorParams - // (no UI / RtxOption); the literal here matches and keeps the call - // site self-evident. The final `saturate(color)` fallback at the - // bottom of this function enforces the [0, 1] envelope on the - // identity/unrecognised path; psycho17's own gamut compression - // handles its own clamp. float3 psychoResult = renodx::tonemap::psycho::psychotm_test17( color, /*peak_value=*/ 1.0f, diff --git a/src/dxvk/shaders/rtx/pass/tonemap/tonemapping.h b/src/dxvk/shaders/rtx/pass/tonemap/tonemapping.h index 1e7031d9d..190ee530d 100644 --- a/src/dxvk/shaders/rtx/pass/tonemap/tonemapping.h +++ b/src/dxvk/shaders/rtx/pass/tonemap/tonemapping.h @@ -70,15 +70,15 @@ static const uint32_t tonemapOperatorNeutwo = 8; // Renodx Neutwo per-c // exponential dynamics (lightAdaptTau when brightening, // darkAdaptTau when dimming). struct ToneMappingAutoExposureArgs { - uint numPixels; + uint numPixels; // Total frame pixel count; used only for debug histogram normalization. float lightAdaptTau; // Time constant (s) when adapting to a brighter scene (photopic, fast). float darkAdaptTau; // Time constant (s) when adapting to a darker scene (scotopic, slow). float deltaTime; // Frame delta in seconds. - uint debugMode; // 1 => write the exposure-histogram debug visualization. + uint debugMode; // 1 => write the exposure-histogram debug visualization. + float targetAdaptedYf; // Mid-gray adaptation target (default 0.18); raise/lower to bias brightness. + float maxExposure; // Hard ceiling on the auto-exposure multiplier; caps brightening in dark scenes. uint pad0; - uint pad1; - uint pad2; }; // Inputs for the apply-tonemapping pass. The dynamic tone curve and