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
8 changes: 6 additions & 2 deletions src/dxvk/rtx_render/rtx_auto_exposure.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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);

{
Expand Down
9 changes: 9 additions & 0 deletions src/dxvk/rtx_render/rtx_auto_exposure.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
};

}
2 changes: 1 addition & 1 deletion src/dxvk/rtx_render/rtx_fork_tonemap.h
Original file line number Diff line number Diff line change
Expand Up @@ -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, "
Expand Down
119 changes: 41 additions & 78 deletions src/dxvk/shaders/rtx/pass/tonemap/auto_exposure.comp.slang
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -67,78 +67,48 @@ 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;
if (linearIndex >= 1 && pixelCount > 0)
{
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)
{
Expand All @@ -155,52 +125,45 @@ 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
// (rod recovery), so the time-constant flips with the direction of
// 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);
}

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions src/dxvk/shaders/rtx/pass/tonemap/tonemapping.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down