From 935830a7b312495e4ddb9b4c2698aca9cd5ee535 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Tue, 11 Aug 2026 04:32:09 -0400 Subject: [PATCH 01/22] feat(elitedangerous): integrate PsychoV25 components into custom tonemap --- .../tonemap/psychov25/acc_dkl.hlsli | 354 ++ .../tonemap/psychov25/bleaching.hlsli | 95 + .../tonemap/psychov25/nrg.hlsli | 919 ++++ .../tonemap/psychov25/stockman.hlsli | 112 + .../tonemap/psychov25/test25.hlsli | 4085 +++++++++++++++++ .../elitedangerous/tonemap/tonemap.hlsli | 704 ++- 6 files changed, 6020 insertions(+), 249 deletions(-) create mode 100644 src/games/elitedangerous/tonemap/psychov25/acc_dkl.hlsli create mode 100644 src/games/elitedangerous/tonemap/psychov25/bleaching.hlsli create mode 100644 src/games/elitedangerous/tonemap/psychov25/nrg.hlsli create mode 100644 src/games/elitedangerous/tonemap/psychov25/stockman.hlsli create mode 100644 src/games/elitedangerous/tonemap/psychov25/test25.hlsli diff --git a/src/games/elitedangerous/tonemap/psychov25/acc_dkl.hlsli b/src/games/elitedangerous/tonemap/psychov25/acc_dkl.hlsli new file mode 100644 index 000000000..97f8740a3 --- /dev/null +++ b/src/games/elitedangerous/tonemap/psychov25/acc_dkl.hlsli @@ -0,0 +1,354 @@ +#ifndef SRC_SHADERS_COLOR_ACC_DKL_HLSL_ +#define SRC_SHADERS_COLOR_ACC_DKL_HLSL_ + +#include "./stockman.hlsli" + +namespace renodx { +namespace color { + +namespace acc { +// Generic ACC algebra: +// - lms_white defines the opponent matrix coefficients (mc1, mc2) +// - lms_background defines the operating point that LMS is delta'd against +// - matrix overloads allow a fully folded fast path when white/background are fixed +// - weighted/unweighted LMS use the same algebra; the separate entry points make +// the caller's basis choice explicit +static const float EPSILON = 1e-6f; + +float3 SafeLMSWhite(float3 lms_white = 1) { + return max(abs(lms_white), EPSILON.xxx); +} + +float2 ParamsFromLMSWhite(float3 lms_white = 1) { + float3 white = SafeLMSWhite(lms_white); + return float2( + renodx::math::DivideSafe(white.x, white.y, 0), + renodx::math::DivideSafe(white.x + white.y, white.z, 0)); +} + +float2 ParamsFromWeightedLMSWhite(float3 lms_weighted_white = 1) { + return ParamsFromLMSWhite(lms_weighted_white); +} + +float3x3 LMSDeltaToACCMatrix(float3 lms_white = 1) { + float2 acc_params = ParamsFromLMSWhite(lms_white); + float mc1 = acc_params.x; + float mc2 = acc_params.y; + + return float3x3( + 1.00000000f, 1.00000000f, 0.00000000f, + 1.00000000f, -mc1, 0.00000000f, + -1.00000000f, -1.00000000f, mc2); +} + +float3x3 WeightedLMSDeltaToACCMatrix(float3 lms_weighted_white = 1) { + return LMSDeltaToACCMatrix(lms_weighted_white); +} + +float3x3 ACCToLMSDeltaMatrix(float3 lms_white = 1) { + float2 acc_params = ParamsFromLMSWhite(lms_white); + float mc1 = acc_params.x; + float mc2 = acc_params.y; + + float inv_lm = renodx::math::DivideSafe(1.f, 1.f + mc1, 0); + float inv_s = renodx::math::DivideSafe(1.f, mc2, 0); + + return float3x3( + mc1 * inv_lm, inv_lm, 0.00000000f, + inv_lm, -inv_lm, 0.00000000f, + inv_s, 0.00000000f, inv_s); +} + +float3x3 ACCToWeightedLMSDeltaMatrix(float3 lms_weighted_white = 1) { + return ACCToLMSDeltaMatrix(lms_weighted_white); +} + +float3 BiasFromLMSBackground(float3x3 lms_delta_to_acc_mat, float3 lms_background = 0) { + return -mul(lms_delta_to_acc_mat, lms_background); +} + +namespace from { +float3 LMSDelta(float3 delta_lms, float3 lms_white = 1) { + return mul(LMSDeltaToACCMatrix(lms_white), delta_lms); +} + +float3 LMSDelta(float3 delta_lms, float3x3 lms_delta_to_acc_mat) { + return mul(lms_delta_to_acc_mat, delta_lms); +} + +float3 WeightedLMSDelta(float3 delta_lms_weighted, float3 lms_weighted_white = 1) { + return mul(WeightedLMSDeltaToACCMatrix(lms_weighted_white), delta_lms_weighted); +} + +float3 WeightedLMSDelta(float3 delta_lms_weighted, float3x3 weighted_lms_delta_to_acc_mat) { + return mul(weighted_lms_delta_to_acc_mat, delta_lms_weighted); +} + +float3 LMS(float3 lms, float3 lms_white = 1, float3 lms_background = 0) { + return LMSDelta(lms - lms_background, lms_white); +} + +float3 LMS(float3 lms, float3x3 lms_to_acc_mat, float3 acc_bias = 0) { + return mul(lms_to_acc_mat, lms) + acc_bias; +} + +float3 WeightedLMS(float3 lms_weighted, float3 lms_weighted_white = 1, + float3 lms_weighted_background = 0) { + return WeightedLMSDelta(lms_weighted - lms_weighted_background, lms_weighted_white); +} + +float3 WeightedLMS(float3 lms_weighted, float3x3 weighted_lms_to_acc_mat, float3 acc_bias = 0) { + return mul(weighted_lms_to_acc_mat, lms_weighted) + acc_bias; +} +} // namespace from + +namespace to { +float3 LMSDelta(float3 acc_value, float3 lms_white = 1) { + return mul(ACCToLMSDeltaMatrix(lms_white), acc_value); +} + +float3 LMSDelta(float3 acc_value, float3x3 acc_to_lms_delta_mat) { + return mul(acc_to_lms_delta_mat, acc_value); +} + +float3 WeightedLMSDelta(float3 acc_value, float3 lms_weighted_white = 1) { + return mul(ACCToWeightedLMSDeltaMatrix(lms_weighted_white), acc_value); +} + +float3 WeightedLMSDelta(float3 acc_value, float3x3 acc_to_weighted_lms_delta_mat) { + return mul(acc_to_weighted_lms_delta_mat, acc_value); +} + +float3 LMS(float3 acc_value, float3 lms_white = 1, float3 lms_background = 0) { + return LMSDelta(acc_value, lms_white) + lms_background; +} + +float3 LMS(float3 acc_value, float3x3 acc_to_lms_delta_mat, float3 lms_background = 0) { + return mul(acc_to_lms_delta_mat, acc_value) + lms_background; +} + +float3 WeightedLMS(float3 acc_value, float3 lms_weighted_white = 1, + float3 lms_weighted_background = 0) { + return WeightedLMSDelta(acc_value, lms_weighted_white) + lms_weighted_background; +} + +float3 WeightedLMS(float3 acc_value, float3x3 acc_to_weighted_lms_delta_mat, + float3 lms_weighted_background = 0) { + return mul(acc_to_weighted_lms_delta_mat, acc_value) + lms_weighted_background; +} +} // namespace to +} // namespace acc + +namespace dkl { +namespace from { +float3 LMSDelta(float3 delta_lms, float3 lms_white = 1) { + return acc::from::LMSDelta(delta_lms, lms_white); +} + +float3 LMSDelta(float3 delta_lms, float3x3 lms_delta_to_dkl_mat) { + return acc::from::LMSDelta(delta_lms, lms_delta_to_dkl_mat); +} + +float3 WeightedLMSDelta(float3 delta_lms_weighted, float3 lms_weighted_white = 1) { + return acc::from::WeightedLMSDelta(delta_lms_weighted, lms_weighted_white); +} + +float3 WeightedLMSDelta(float3 delta_lms_weighted, float3x3 weighted_lms_delta_to_dkl_mat) { + return acc::from::WeightedLMSDelta(delta_lms_weighted, weighted_lms_delta_to_dkl_mat); +} + +float3 LMS(float3 lms, float3 lms_white = 1, float3 lms_background = 0) { + return acc::from::LMS(lms, lms_white, lms_background); +} + +float3 LMS(float3 lms, float3x3 lms_to_dkl_mat, float3 dkl_bias = 0) { + return acc::from::LMS(lms, lms_to_dkl_mat, dkl_bias); +} + +float3 WeightedLMS(float3 lms_weighted, float3 lms_weighted_white = 1, + float3 lms_weighted_background = 0) { + return acc::from::WeightedLMS(lms_weighted, lms_weighted_white, lms_weighted_background); +} + +float3 WeightedLMS(float3 lms_weighted, float3x3 weighted_lms_to_dkl_mat, float3 dkl_bias = 0) { + return acc::from::WeightedLMS(lms_weighted, weighted_lms_to_dkl_mat, dkl_bias); +} +} // namespace from + +namespace to { +float3 LMSDelta(float3 dkl_value, float3 lms_white = 1) { + return acc::to::LMSDelta(dkl_value, lms_white); +} + +float3 LMSDelta(float3 dkl_value, float3x3 dkl_to_lms_delta_mat) { + return acc::to::LMSDelta(dkl_value, dkl_to_lms_delta_mat); +} + +float3 WeightedLMSDelta(float3 dkl_value, float3 lms_weighted_white = 1) { + return acc::to::WeightedLMSDelta(dkl_value, lms_weighted_white); +} + +float3 WeightedLMSDelta(float3 dkl_value, float3x3 dkl_to_weighted_lms_delta_mat) { + return acc::to::WeightedLMSDelta(dkl_value, dkl_to_weighted_lms_delta_mat); +} + +float3 LMS(float3 dkl_value, float3 lms_white = 1, float3 lms_background = 0) { + return acc::to::LMS(dkl_value, lms_white, lms_background); +} + +float3 LMS(float3 dkl_value, float3x3 dkl_to_lms_delta_mat, float3 lms_background = 0) { + return acc::to::LMS(dkl_value, dkl_to_lms_delta_mat, lms_background); +} + +float3 WeightedLMS(float3 dkl_value, float3 lms_weighted_white = 1, + float3 lms_weighted_background = 0) { + return acc::to::WeightedLMS(dkl_value, lms_weighted_white, lms_weighted_background); +} + +float3 WeightedLMS(float3 dkl_value, float3x3 dkl_to_weighted_lms_delta_mat, + float3 lms_weighted_background = 0) { + return acc::to::WeightedLMS(dkl_value, dkl_to_weighted_lms_delta_mat, lms_weighted_background); +} +} // namespace to +} // namespace dkl + +namespace stockman { +namespace acc { +// Concrete Stockman ACC uses Stockman D65 as the white that defines the matrix. +// The optional background remains caller-controlled and defaults to zero delta. +float3 LMSWhite() { + return renodx::color::lms::from::WhiteD65(); +} + +float2 Params() { + return renodx::color::acc::ParamsFromLMSWhite(LMSWhite()); +} + +float3x3 LMSDeltaToACCMatrix() { + return renodx::color::acc::LMSDeltaToACCMatrix(LMSWhite()); +} + +float3x3 ACCToLMSDeltaMatrix() { + return renodx::color::acc::ACCToLMSDeltaMatrix(LMSWhite()); +} + +float3x3 LMSD65ToACCMatrix() { + return renodx::color::acc::LMSDeltaToACCMatrix(1); +} + +float3x3 ACCToLMSD65Matrix() { + return renodx::color::acc::ACCToLMSDeltaMatrix(1); +} + +namespace from { +float3 LMSDelta(float3 delta_lms) { + return renodx::color::acc::from::LMSDelta(delta_lms, stockman::acc::LMSDeltaToACCMatrix()); +} + +float3 LMS(float3 lms_abs, float3 lms_background = 0) { + return renodx::color::acc::from::LMS( + lms_abs, + stockman::acc::LMSDeltaToACCMatrix(), + renodx::color::acc::BiasFromLMSBackground( + stockman::acc::LMSDeltaToACCMatrix(), + lms_background)); +} + +float3 BT709(float3 bt709, float3 lms_background = 0) { + return LMS(lms::from::BT709(bt709), lms_background); +} + +float3 BT2020(float3 bt2020, float3 lms_background = 0) { + return LMS(lms::from::BT2020(bt2020), lms_background); +} + +float3 LMSD65(float3 lms_d65, float3 lms_background = 0) { + return renodx::color::acc::from::LMS( + lms_d65, + stockman::acc::LMSD65ToACCMatrix(), + renodx::color::acc::BiasFromLMSBackground( + stockman::acc::LMSD65ToACCMatrix(), + lms_background)); +} +} // namespace from + +namespace to { +float3 LMSDelta(float3 acc_value) { + return renodx::color::acc::to::LMSDelta(acc_value, stockman::acc::ACCToLMSDeltaMatrix()); +} + +float3 LMS(float3 acc_value, float3 lms_background = 0) { + return renodx::color::acc::to::LMS( + acc_value, + stockman::acc::ACCToLMSDeltaMatrix(), + lms_background); +} + +float3 BT709(float3 acc_value, float3 lms_background = 0) { + return bt709::from::LMS(LMS(acc_value, lms_background)); +} + +float3 BT2020(float3 acc_value, float3 lms_background = 0) { + return bt2020::from::LMS(LMS(acc_value, lms_background)); +} + +float3 LMSD65(float3 acc_value, float3 lms_background = 0) { + return renodx::color::acc::to::LMS( + acc_value, + stockman::acc::ACCToLMSD65Matrix(), + lms_background); +} +} // namespace to +} // namespace acc + +namespace dkl { +namespace from { +float3 LMSDelta(float3 delta_lms) { + return acc::from::LMSDelta(delta_lms); +} + +float3 LMS(float3 lms_abs, float3 lms_background = 0) { + return acc::from::LMS(lms_abs, lms_background); +} + +float3 BT709(float3 bt709, float3 lms_background = 0) { + return acc::from::BT709(bt709, lms_background); +} + +float3 BT2020(float3 bt2020, float3 lms_background = 0) { + return acc::from::BT2020(bt2020, lms_background); +} + +float3 LMSD65(float3 lms_d65, float3 lms_background = 0) { + return acc::from::LMSD65(lms_d65, lms_background); +} +} // namespace from + +namespace to { +float3 LMSDelta(float3 dkl_value) { + return acc::to::LMSDelta(dkl_value); +} + +float3 LMS(float3 dkl_value, float3 lms_background = 0) { + return acc::to::LMS(dkl_value, lms_background); +} + +float3 BT709(float3 dkl_value, float3 lms_background = 0) { + return acc::to::BT709(dkl_value, lms_background); +} + +float3 BT2020(float3 dkl_value, float3 lms_background = 0) { + return acc::to::BT2020(dkl_value, lms_background); +} + +float3 LMSD65(float3 dkl_value, float3 lms_background = 0) { + return acc::to::LMSD65(dkl_value, lms_background); +} +} // namespace to +} // namespace dkl +} // namespace stockman + +} // namespace color +} // namespace renodx + +#endif // SRC_SHADERS_COLOR_ACC_DKL_HLSL_ diff --git a/src/games/elitedangerous/tonemap/psychov25/bleaching.hlsli b/src/games/elitedangerous/tonemap/psychov25/bleaching.hlsli new file mode 100644 index 000000000..b1b5a9146 --- /dev/null +++ b/src/games/elitedangerous/tonemap/psychov25/bleaching.hlsli @@ -0,0 +1,95 @@ +#ifndef SRC_SHADERS_COLOR_BLEACHING_HLSL_ +#define SRC_SHADERS_COLOR_BLEACHING_HLSL_ + +#include "../../common.hlsli" + +namespace renodx { +namespace color { +namespace bleaching { + +namespace rushton_henry { + +static const float CONE_HALF_BLEACH_TROLANDS = 20000.f; + +// One-sided availability limiter in adapted units. +// p(r) = 1 / (1 + r / r0) +// Source direction: same algebraic form as the steady-state cone bleaching law +// used by Rushton & Henry (1968), commonly written for fraction bleached as +// p_bleached(I) = I / (I + I0) +// with I in photopic trolands and I0 ~ 10^4.3 Td for cones. This helper uses +// the complementary fraction +// p_available(I) = 1 - p_bleached(I) = I0 / (I + I0) +// because the shader attenuates available cone drive rather than tracking the +// bleached fraction directly. +// Secondary source with the equation stated explicitly: +// Stockman, Henning, Smithson, & Rider (JOV 2018, 18(6):12), appendix note: +// "p = I / (I + I0)", with I0 = 10^4.3 Td, citing Rushton & Henry (1968). +float AvailabilityFromRelativeDrive(float relative_drive, float knee_ratio) { + return 1.f / (1.f + relative_drive / knee_ratio); +} + +// Absolute trolands form of the same availability law. +// p(I) = 1 / (1 + I / I0) +float AvailabilityFromTrolands(float retinal_illuminance_trolands, + float half_bleach_trolands = CONE_HALF_BLEACH_TROLANDS) { + return 1.f / (1.f + retinal_illuminance_trolands / half_bleach_trolands); +} + +float3 AvailabilityFromTrolands(float3 retinal_illuminance_trolands, + float half_bleach_trolands = CONE_HALF_BLEACH_TROLANDS) { + return float3( + AvailabilityFromTrolands(retinal_illuminance_trolands.x, half_bleach_trolands), + AvailabilityFromTrolands(retinal_illuminance_trolands.y, half_bleach_trolands), + AvailabilityFromTrolands(retinal_illuminance_trolands.z, half_bleach_trolands)); +} + +} // namespace rushton_henry + +// White-relative per-cone attenuation: +// - Keeps a white anchor at the same L+M level as the input. +// - Applies independent cone gains to LMS deltas around that anchor. +// Engineering interpretation: +// - The bleaching source law above constrains available pigment / sensitivity. +// - The specific "bleach toward white at the same carried achromatic level" +// behavior implemented here is the repo's rendering model for color signals, +// not a literal equation from Rushton & Henry. It is chosen so that strong +// bleaching suppresses cone-opponent excursions while preserving the +// achromatic anchor. +// - CVRL notes that bleaching also reduces effective photopigment density and +// therefore narrows spectral sensitivity without shifting lambda_max. This +// helper does not model that wavelength-dependent narrowing; it is a +// first-order scalar availability approximation intended for rendering. +// - CVRL also notes that a reliable S-cone half-bleaching constant has not +// been established. The shared cone knee used here is therefore an +// engineering approximation rather than a fully resolved per-cone +// physiological model. +float3 ApplyAvailabilityToLMSPerConeWhiteRelative(float3 lms, float3 availability_lms, + float3 white_lms) { + float y = lms.x + lms.y; + float white_y = white_lms.x + white_lms.y; + float3 white_at_y = white_lms * (y / white_y); + float3 delta = lms - white_at_y; + delta *= availability_lms; + + return white_at_y + delta; +} + +float3 ComputeAvailabilityFromAdaptedLMS(float3 adapted_lms, float blend, + float diffuse_white_nits = 100.f, + float pupil_area_mm2 = 10.f, + float half_bleach_trolands = + rushton_henry::CONE_HALF_BLEACH_TROLANDS) { + float3 stimulus_trolands = max(adapted_lms, 0) * diffuse_white_nits * pupil_area_mm2; + float3 availability = rushton_henry::AvailabilityFromTrolands( + stimulus_trolands, half_bleach_trolands); + + return lerp(1.f, availability, blend); +} + + + +} // namespace bleaching +} // namespace color +} // namespace renodx + +#endif // SRC_SHADERS_COLOR_BLEACHING_HLSL_ diff --git a/src/games/elitedangerous/tonemap/psychov25/nrg.hlsli b/src/games/elitedangerous/tonemap/psychov25/nrg.hlsli new file mode 100644 index 000000000..dd86e5fe4 --- /dev/null +++ b/src/games/elitedangerous/tonemap/psychov25/nrg.hlsli @@ -0,0 +1,919 @@ +#ifndef RENODX_SHADERS_TONEMAP_NRG_HLSL_ +#define RENODX_SHADERS_TONEMAP_NRG_HLSL_ + +#include "../../common.hlsli" +#include "./acc_dkl.hlsli" +#include "./bleaching.hlsli" +#include "./stockman.hlsli" + +namespace renodx { +namespace tonemap { +namespace nrg { + +static const int NRG_BLEACH_MODEL_SCALAR = 0; +static const int NRG_BLEACH_MODEL_PER_CONE = 1; +static const int NRG_TEST5_ENERGY_BT2020_ABS_SUM = 0; +static const int NRG_TEST5_ENERGY_LMS_D65_ABS_SUM = 1; +static const int NRG_TEST5_ENERGY_ACC_A = 2; +static const int NRG_TEST6_CURVE_RH = 0; +static const int NRG_TEST6_CURVE_NR = 1; +// Wider blend to avoid abrupt dark/bright branch flicker around the adaptation anchor. +static const float NRG_TEST6_SIGN_BLEND_WIDTH = 0.08f; +// Test5 target: reach max chroma at 25% RH/Yf-relative progress. +static const float NRG_TEST5_P_WALL = 0.25f; +// CastleCSF uses absolute luminance units (cd/m^2). +// For this test path, scene values are mapped into [min_nits, max_nits] +// where max_nits scales with peak. +static const float NRG_TEST6_CASTLE_MIN_NITS = 0.005f; +static const float NRG_TEST6_CASTLE_BASE_NITS = 100.f; // max_nits when peak == 1 +static const float NRG_TEST6_CASTLE_BACKGROUND_NITS = 5.f; +static const float NRG_TEST6_CASTLE_RHO_CPD = 1.f; +static const float NRG_TEST6_CASTLE_OMEGA_HZ = 0.f; +static const float NRG_TEST6_CASTLE_ECC_DEG = 0.f; +static const float NRG_TEST6_CASTLE_VIS_FIELD_DEG = 0.f; +static const float NRG_TEST6_CASTLE_AREA_DEG2 = 3.14159265f; + +// Anchored Rushton-Henry scalar response for NRGTest4. +// Uses RH availability in relative-drive space, normalized so: +// - y(gray_anchor) = gray_anchor +// - y(infinity) -> peak +float NRGTest4ScalarRushtonHenryToPeak(float x_unit, float peak) { + const float kEps = 1e-6f; + const float kGrayAnchorDefault = 0.18f; + + float p = max(peak, kEps); + float g = min(kGrayAnchorDefault, p * 0.5f); + g = max(g, kEps); + + float relative_drive = max(renodx::math::DivideSafe(max(x_unit, 0.f), g, 0.f), 0.f); + float knee_ratio = max(renodx::math::DivideSafe(p, g, 0.f) - 1.f, kEps); + + float availability = + renodx::color::bleaching::rushton_henry::AvailabilityFromRelativeDrive( + relative_drive, + knee_ratio); + float availability_at_gray = + renodx::color::bleaching::rushton_henry::AvailabilityFromRelativeDrive( + 1.f, + knee_ratio); + + float drive_out = relative_drive * availability; + float drive_out_normalized = + renodx::math::DivideSafe(drive_out, availability_at_gray, 0.f); + + float y = g * drive_out_normalized; + return min(max(y, 0.f), p); +} + +bool IntersectLinearBoundedInterval( + float x0, + float dx, + float min_value, + float max_value, + inout float k_lo, + inout float k_hi) { + const float kSlopeEps = 1e-8f; + if (abs(dx) <= kSlopeEps) { + return x0 >= min_value && x0 <= max_value; + } + + float t0 = renodx::math::DivideSafe(min_value - x0, dx, 0.f); + float t1 = renodx::math::DivideSafe(max_value - x0, dx, 0.f); + float t_min = min(t0, t1); + float t_max = max(t0, t1); + + k_lo = max(k_lo, t_min); + k_hi = min(k_hi, t_max); + return k_hi >= k_lo; +} + +float ComputeAbsSum(float3 v) { + return abs(v.x) + abs(v.y) + abs(v.z); +} + +float NRGTest6PeakWhiteNits(float peak) { + const float kEps = 1e-6f; + float peak_ref = max(peak, kEps); + return max(NRG_TEST6_CASTLE_BASE_NITS * peak_ref, NRG_TEST6_CASTLE_MIN_NITS + kEps); +} + +float3 NRGTest6StimulusNits(float3 bt2020_linear, float peak) { + const float kEps = 1e-6f; + float peak_ref = max(peak, kEps); + float white_nits = NRGTest6PeakWhiteNits(peak_ref); + float3 scene_unit = saturate(bt2020_linear / peak_ref); + return lerp(NRG_TEST6_CASTLE_MIN_NITS.xxx, white_nits.xxx, scene_unit); +} + +float NRGTest6BackgroundYCdM2( + float peak, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { + float white_nits = NRGTest6PeakWhiteNits(peak); + return clamp(background_nits, NRG_TEST6_CASTLE_MIN_NITS, white_nits); +} + +float NRGTest6JNDScalarRaw( + float3 bt2020_linear, + float peak, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { + const float kEps = 1e-6f; + float peak_ref = max(peak, kEps); + float Y0_cd_m2 = NRGTest6BackgroundYCdM2(peak_ref, background_nits); + + // Match CastleCSFOld's relative-drive convention: + // delta is background-relative LMS contrast, then CastleCSF converts to ACC/DKL internally. + float3 stimulus_nits = NRGTest6StimulusNits(bt2020_linear, peak_ref); + float3 lms_stimulus = renodx::color::lms::from::BT2020(stimulus_nits); + float3 xyz_background = renodx::color::xyz::from::xyY(float3(0.31272f, 0.32903f, max(Y0_cd_m2, 1e-4f))); + float3 lms_background = renodx::color::lms::from::XYZ(xyz_background); + float3 delta_lms_relative = (lms_stimulus - lms_background) / max(abs(lms_background), kEps.xxx); + + float4 energy = renodx::color::castlecsf::CastleCSF_Energy( + delta_lms_relative, + max(Y0_cd_m2, 1e-4f), + NRG_TEST6_CASTLE_RHO_CPD, + NRG_TEST6_CASTLE_OMEGA_HZ, + NRG_TEST6_CASTLE_ECC_DEG, + NRG_TEST6_CASTLE_VIS_FIELD_DEG, + NRG_TEST6_CASTLE_AREA_DEG2); + + return max(energy.w, 0.f); +} + +float NRGTest6SignedAchromaticContrast( + float3 bt2020_linear, + float peak = 1.f, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { + const float kEps = 1e-6f; + float peak_ref = max(peak, kEps); + float3 stimulus_nits = NRGTest6StimulusNits(bt2020_linear, peak_ref); + float3 lms_stimulus = renodx::color::lms::from::BT2020(stimulus_nits); + float Y0_cd_m2 = NRGTest6BackgroundYCdM2(peak_ref, background_nits); + float3 xyz_background = renodx::color::xyz::from::xyY(float3(0.31272f, 0.32903f, max(Y0_cd_m2, 1e-4f))); + float3 lms_background = renodx::color::lms::from::XYZ(xyz_background); + + float achromatic_stimulus = lms_stimulus.x + lms_stimulus.y; + float achromatic_background = lms_background.x + lms_background.y; + return renodx::math::DivideSafe( + achromatic_stimulus - achromatic_background, + max(abs(achromatic_background), kEps), + 0.f); +} + +float NRGTest6JNDPeakZeroRaw( + float3 bt2020_linear, + float peak, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { + const float kEps = 1e-6f; + float peak_ref = max(peak, kEps); + float Y0_cd_m2 = NRGTest6BackgroundYCdM2(peak_ref, background_nits); + + float3 lms_stimulus = renodx::color::lms::from::BT2020( + NRGTest6StimulusNits(bt2020_linear, peak_ref)); + float3 lms_black = renodx::color::lms::from::BT2020( + NRGTest6StimulusNits(0, peak_ref)); + float3 lms_background = renodx::color::lms::from::XYZ( + renodx::color::xyz::from::xyY(float3(0.31272f, 0.32903f, max(Y0_cd_m2, 1e-4f)))); + + float3 delta_lms_relative = (lms_stimulus - lms_black) / max(abs(lms_background), kEps.xxx); + float4 energy = renodx::color::castlecsf::CastleCSF_Energy( + delta_lms_relative, + max(Y0_cd_m2, 1e-4f), + NRG_TEST6_CASTLE_RHO_CPD, + NRG_TEST6_CASTLE_OMEGA_HZ, + NRG_TEST6_CASTLE_ECC_DEG, + NRG_TEST6_CASTLE_VIS_FIELD_DEG, + NRG_TEST6_CASTLE_AREA_DEG2); + return max(energy.w, 0.f); +} + +void NRGTest6PerceptualDetailBudgetRaw( + float peak, + float background_nits, + out float detail_budget_dark_raw, + out float detail_budget_bright_raw, + out float detail_budget_max_raw) { + const float kEps = 1e-6f; + float peak_ref = max(peak, kEps); + + // Available perceptual range around the adaptation point: + // - dark side: adaptation -> minimum display luminance + // - bright side: adaptation -> peak white + detail_budget_dark_raw = max( + NRGTest6JNDScalarRaw(0, peak_ref, background_nits), + kEps); + detail_budget_bright_raw = max( + NRGTest6JNDScalarRaw(peak_ref.xxx, peak_ref, background_nits), + kEps); + detail_budget_max_raw = max(detail_budget_dark_raw, detail_budget_bright_raw); +} + +float NRGTest6CurveBudgetUnit( + float budget_unit, + int curve_mode = NRG_TEST6_CURVE_RH) { + float x = saturate(budget_unit); + if (curve_mode == NRG_TEST6_CURVE_NR) { + return saturate(renodx::tonemap::NakaRushton( + x, + 1.f, + 0.18f, + 0.18f, + 1.f)); + } + // Default: feed budget-normalized magnitude into the same RH line used by NRGTest4. + return NRGTest4ScalarRushtonHenryToPeak(x, 1.f); +} + +float NRGTest6CurveBudgetUnitAnchored( + float budget_unit, + int curve_mode = NRG_TEST6_CURVE_RH) { + const float kEps = 1e-6f; + float x = saturate(budget_unit); + float y0 = NRGTest6CurveBudgetUnit(0.f, curve_mode); + float y1 = NRGTest6CurveBudgetUnit(1.f, curve_mode); + float y = NRGTest6CurveBudgetUnit(x, curve_mode); + return saturate(renodx::math::DivideSafe(y - y0, max(y1 - y0, kEps), 0.f)); +} + +float3 SolveLineByJNDScalar( + float3 start_bt2020, + float3 end_bt2020, + float peak, + float target_scalar_raw, + out float scalar_out_raw, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { + const float kEps = 1e-6f; + const int kIterations = 16; + float peak_ref = max(peak, kEps); + + float scalar_start = NRGTest6JNDPeakZeroRaw(start_bt2020, peak_ref, background_nits); + float scalar_end = NRGTest6JNDPeakZeroRaw(end_bt2020, peak_ref, background_nits); + bool increasing = scalar_end >= scalar_start; + + if ((increasing && target_scalar_raw <= scalar_start + kEps) || (!increasing && target_scalar_raw >= scalar_start - kEps)) { + scalar_out_raw = scalar_start; + return start_bt2020; + } + if ((increasing && target_scalar_raw >= scalar_end - kEps) || (!increasing && target_scalar_raw <= scalar_end + kEps)) { + scalar_out_raw = scalar_end; + return end_bt2020; + } + + float lo = 0.f; + float hi = 1.f; + + [unroll] + for (int i = 0; i < kIterations; ++i) { + float mid = 0.5f * (lo + hi); + float3 sample_bt2020 = lerp(start_bt2020, end_bt2020, mid); + float scalar_sample = NRGTest6JNDPeakZeroRaw(sample_bt2020, peak_ref, background_nits); + if ((increasing && scalar_sample < target_scalar_raw) || (!increasing && scalar_sample > target_scalar_raw)) { + lo = mid; + } else { + hi = mid; + } + } + + float t = 0.5f * (lo + hi); + float3 bt2020_out = lerp(start_bt2020, end_bt2020, t); + scalar_out_raw = NRGTest6JNDPeakZeroRaw(bt2020_out, peak_ref, background_nits); + return bt2020_out; +} + +float3 BlendChromaAndWhiteSpillJND( + float3 bt2020_chroma, + float3 bt2020_chroma_max, + float peak, + float scalar_output_raw, + float scalar_chroma_max, + float scalar_white_raw, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { + float scalar_final_raw; + float3 bt2020_spill = SolveLineByJNDScalar( + bt2020_chroma_max, + peak.xxx, + peak, + scalar_output_raw, + scalar_final_raw, + background_nits); + + float wall_width = max(0.02f * scalar_white_raw, 1e-6f); + float wall_mix = smoothstep( + scalar_chroma_max - wall_width, + scalar_chroma_max + wall_width, + scalar_output_raw); + + return lerp(bt2020_chroma, bt2020_spill, wall_mix); +} + +// Find first in-gamut point on the line from bt2020_input toward neutral white (peak,peak,peak): +// p(t) = bt2020_input + t * (white - bt2020_input), t in [0,1] +// We return t_lo (entry point from out-of-gamut side), which is guaranteed to exist +// because t=1 is always white and in gamut. +float SolveBT2020BoundaryTowardWhite( + float3 bt2020_input, + float peak, + out float3 out_bt2020) { + float3 white = peak.xxx; + float3 delta = white - bt2020_input; + + float t_lo = 0.f; + float t_hi = 1.f; + if (!IntersectLinearBoundedInterval(bt2020_input.x, delta.x, 0.f, peak, t_lo, t_hi) || !IntersectLinearBoundedInterval(bt2020_input.y, delta.y, 0.f, peak, t_lo, t_hi) || !IntersectLinearBoundedInterval(bt2020_input.z, delta.z, 0.f, peak, t_lo, t_hi)) { + out_bt2020 = white; + return 1.f; + } + + out_bt2020 = bt2020_input + delta * t_lo; + return t_lo; +} + +float3 ComputeBT2020ChromaMaxFromInput(float3 bt2020_linear, float peak_ref, float kEps) { + float3 bt2020_chroma_max; + + float max_channel = max(max(bt2020_linear.x, bt2020_linear.y), bt2020_linear.z); + bool use_bt2020_hue_boundary = all(bt2020_linear >= 0) && max_channel > kEps; + if (use_bt2020_hue_boundary) { + float3 bt2020_hue_unit = bt2020_linear / max_channel; + bt2020_chroma_max = bt2020_hue_unit * peak_ref; + } else { + // Signed/out-of-gamut input: + // preserve usable hue direction from positive BT.2020 components. + float3 bt2020_positive = max(bt2020_linear, 0); + float positive_max = max(max(bt2020_positive.x, bt2020_positive.y), bt2020_positive.z); + if (positive_max > kEps) { + float3 bt2020_hue_unit = bt2020_positive / positive_max; + bt2020_chroma_max = bt2020_hue_unit * peak_ref; + } else { + SolveBT2020BoundaryTowardWhite( + bt2020_linear, + peak_ref, + bt2020_chroma_max); + } + } + + return bt2020_chroma_max; +} + +// Solve t in out = lerp(bt2020_start, peak_white, t) such that +// abs-sum energy in BT.2020 channel space matches target_scalar_raw. +float3 SolveWhiteSpillByEnergy( + float3 bt2020_start, + float peak, + float target_scalar_raw, + out float scalar_out_raw) { + const float kEps = 1e-6f; + + float scalar_start = ComputeAbsSum(bt2020_start); + + float3 bt2020_white = peak.xxx; + float scalar_white = ComputeAbsSum(bt2020_white); + + if (target_scalar_raw <= scalar_start + kEps) { + scalar_out_raw = scalar_start; + return bt2020_start; + } + if (target_scalar_raw >= scalar_white - kEps) { + scalar_out_raw = scalar_white; + return bt2020_white; + } + + float t = saturate(renodx::math::DivideSafe( + target_scalar_raw - scalar_start, + scalar_white - scalar_start, + 0.f)); + float3 bt2020_out = lerp(bt2020_start, bt2020_white, t); + scalar_out_raw = ComputeAbsSum(bt2020_out); + return bt2020_out; +} + +// Smooth blend across the chroma wall to avoid a visible derivative kink +// at the handoff between \"scale-to-max-chroma\" and \"spill-to-white\". +float3 BlendChromaAndWhiteSpill( + float3 bt2020_chroma, + float3 bt2020_chroma_max, + float peak, + float scalar_output_raw, + float scalar_chroma_max) { + float scalar_final_raw; + float3 bt2020_spill = SolveWhiteSpillByEnergy( + bt2020_chroma_max, + peak, + scalar_output_raw, + scalar_final_raw); + + float scalar_white = 3.f * max(peak, 1e-6f); + float wall_width = max(0.02f * scalar_white, 1e-6f); + float wall_mix = smoothstep( + scalar_chroma_max - wall_width, + scalar_chroma_max + wall_width, + scalar_output_raw); + + return lerp(bt2020_chroma, bt2020_spill, wall_mix); +} + +float3 FastInputLMSEnergyGray(float3 bt709_linear) { + float3 lms = renodx::color::lms::from::BT709(bt709_linear); + float3 lms_white = renodx::color::lms::from::WhiteD65(1.f); + + float3 lms_norm = lms / lms_white; + float scalar_raw = abs(lms_norm.x) + abs(lms_norm.y) + abs(lms_norm.z); + float scalar_input = scalar_raw / 3.f; + return scalar_input.xxx; +} + +float3 NeutwoBT709WhiteForEnergy(float3 bt709_linear, float peak = 1.f) { + const float kEps = 1e-6f; + const float kType7WhiteUnits = 3.f; + const float kChromaCurve = 1.5f; + + float3 lms = renodx::color::lms::from::BT709(bt709_linear); + float3 lms_white = renodx::color::lms::from::WhiteD65(1.f); + + float3 lms_norm_input = lms / lms_white; + float scalar_input_raw = abs(lms_norm_input.x) + abs(lms_norm_input.y) + abs(lms_norm_input.z); + float scalar_input = scalar_input_raw / kType7WhiteUnits; + + float peak_ref = max(peak, kEps); + float scalar_peak = peak_ref; + float scalar_output = renodx::tonemap::Neutwo(scalar_input, scalar_peak); + + float3 lms_d65 = lms / renodx::color::lms::from::WhiteD65(1.f); + float3 acc_input = renodx::color::stockman::acc::from::LMSD65(lms_d65); + float t = saturate(scalar_output / scalar_peak); + float chroma_scale = 1.f - pow(t, kChromaCurve); + float2 acc_chroma_out = acc_input.yz * chroma_scale; + + float3 lms_white_target_d65 = scalar_output.xxx; + float3 acc_white = renodx::color::stockman::acc::from::LMSD65(lms_white_target_d65); + + float3 acc_out = float3(acc_white.x, acc_chroma_out.x, acc_chroma_out.y); + float3 lms_out_d65 = renodx::color::stockman::acc::to::LMSD65(acc_out); + float3 lms_out = lms_out_d65 * renodx::color::lms::from::WhiteD65(1.f); + + float3 lms_norm_scalar = lms_out / lms_white; + float scalar_out_raw = abs(lms_norm_scalar.x) + abs(lms_norm_scalar.y) + abs(lms_norm_scalar.z); + float scalar_target_raw = scalar_output * kType7WhiteUnits; + float scalar_match_scale = scalar_target_raw / max(scalar_out_raw, kEps); + lms_out *= scalar_match_scale; + + return renodx::color::bt709::from::LMS(lms_out); +} + +float3 NRGTest2(float3 bt709_linear, float peak = 1.f) { + const float kEps = 1e-6f; + const float kUnits = 1.f; + const float strength = 0.18f * peak; + float peak_ref = max(peak, kEps); + + float3 lms = renodx::color::lms::from::BT709(bt709_linear); + float3 lms_white = renodx::color::lms::from::WhiteE(1.f); + + float3 lms_norm_input = lms / lms_white; + float scalar_raw_input = lms_norm_input.x + lms_norm_input.y + lms_norm_input.z; + float scalar_input = scalar_raw_input / kUnits; + + float3 lms_peak = lms_white * peak_ref; + float3 lms_norm_peak = lms_peak / lms_white; + float scalar_raw_peak = lms_norm_peak.x + lms_norm_peak.y + lms_norm_peak.z; + float scalar_peak = scalar_raw_peak / kUnits; + float scalar_output = renodx::tonemap::Neutwo(scalar_input, scalar_peak); + + float scalar_input_raw = scalar_input * kUnits; + float scalar_output_raw = scalar_output * kUnits; + + float3 lms_gray = lms_white * strength; + float3 lms_gray_in = lms_gray * scalar_input_raw; + float3 lms_gray_out = lms_gray * scalar_output_raw; + float3 lms_chroma = lms - lms_gray_in; + float available_white = saturate(renodx::math::DivideSafe( + scalar_peak - scalar_output, + scalar_peak, + 0.f)); + + float3 lms_out = lms_gray_out + lms_chroma * available_white; + float3 lms_norm_out = lms_out / lms_white; + float scalar_out_raw = lms_norm_out.x + lms_norm_out.y + lms_norm_out.z; + lms_out *= renodx::math::DivideSafe(scalar_output_raw, scalar_out_raw, 0.f); + + lms_norm_out = lms_out / lms_white; + scalar_out_raw = lms_norm_out.x + lms_norm_out.y + lms_norm_out.z; + lms_out *= renodx::math::DivideSafe(scalar_output_raw, scalar_out_raw, 0.f); + + float3 bt709_out = renodx::color::bt709::from::LMS(lms_out); + float3 bt2020_out = renodx::color::bt2020::from::BT709(bt709_out); + bt2020_out = clamp(bt2020_out, 0.f, peak_ref.xxx); + return renodx::color::bt709::from::BT2020(bt2020_out); +} + +float3 NRGTest3BT2020(float3 bt2020_linear, float peak = 1.f) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; // BT.2020 abs-sum: white@1 = 3, white@peak = 3*peak. + float peak_ref = max(peak, kEps); + + // Scalar units in BT.2020: + // white@1 = 3, peak(8) = 24. + float scalar_input_raw = ComputeAbsSum(bt2020_linear); + float scalar_input_unit = scalar_input_raw / kScalarWhiteUnits; + float scalar_output_unit = renodx::tonemap::NakaRushton( + scalar_input_unit, + peak_ref, + 0.18f, + 0.18f, + 1.f); + float scalar_output_raw = scalar_output_unit * kScalarWhiteUnits; + float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); + + float scalar_chroma_max = ComputeAbsSum(bt2020_chroma_max); + if (scalar_chroma_max <= kEps) { + // Degenerate case: boundary is black. Move on black->white by scalar budget. + float scalar_final_raw; + return SolveWhiteSpillByEnergy( + 0, + peak_ref, + scalar_output_raw, + scalar_final_raw); + } + + // Chroma budget does NOT pass through Neutwo; only E_in does. + float scalar_chroma = min(scalar_output_raw, scalar_chroma_max); + float chroma_scale = renodx::math::DivideSafe( + scalar_chroma, + scalar_chroma_max, + 0.f); + float3 bt2020_chroma = bt2020_chroma_max * chroma_scale; + return BlendChromaAndWhiteSpill( + bt2020_chroma, + bt2020_chroma_max, + peak_ref, + scalar_output_raw, + scalar_chroma_max); +} + +float3 NRGTest3(float3 bt709_linear, float peak = 1.f) { + float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); + float3 bt2020_out = NRGTest3BT2020(bt2020_linear, peak); + return renodx::color::bt709::from::BT2020(bt2020_out); +} + +float3 NRGTest4BT2020(float3 bt2020_linear, float peak = 1.f) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; // BT.2020 abs-sum: white@1 = 3, white@peak = 3*peak. + float peak_ref = max(peak, kEps); + + // Scalar units in BT.2020: + // white@1 = 3, peak(8) = 24. + float scalar_input_raw = ComputeAbsSum(bt2020_linear); + float scalar_input_unit = scalar_input_raw / kScalarWhiteUnits; + float scalar_output_unit = NRGTest4ScalarRushtonHenryToPeak(scalar_input_unit, peak_ref); + float scalar_output_raw = scalar_output_unit * kScalarWhiteUnits; + float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); + + float scalar_chroma_max = ComputeAbsSum(bt2020_chroma_max); + if (scalar_chroma_max <= kEps) { + // Degenerate case: boundary is black. Move on black->white by scalar budget. + float scalar_final_raw; + return SolveWhiteSpillByEnergy( + 0, + peak_ref, + scalar_output_raw, + scalar_final_raw); + } + + // Chroma budget does NOT pass through Rushton-Henry; only E_in does. + float scalar_chroma = min(scalar_output_raw, scalar_chroma_max); + float chroma_scale = renodx::math::DivideSafe( + scalar_chroma, + scalar_chroma_max, + 0.f); + float3 bt2020_chroma = bt2020_chroma_max * chroma_scale; + return BlendChromaAndWhiteSpill( + bt2020_chroma, + bt2020_chroma_max, + peak_ref, + scalar_output_raw, + scalar_chroma_max); +} + +float3 NRGTest4(float3 bt709_linear, float peak = 1.f) { + float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); + float3 bt2020_out = NRGTest4BT2020(bt2020_linear, peak); + return renodx::color::bt709::from::BT2020(bt2020_out); +} + +float NRGTest5ScalarInputUnit( + float3 bt2020_linear, + int energy_mode = NRG_TEST5_ENERGY_ACC_A) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; + + float3 lms_d65 = renodx::color::lms::from::BT2020(bt2020_linear) / max(renodx::color::lms::from::WhiteD65(1.f), 1e-6f.xxx); + if (energy_mode == NRG_TEST5_ENERGY_ACC_A) { + float3 acc = renodx::color::stockman::acc::from::LMSD65(lms_d65); + float acc_white = max(abs(renodx::color::stockman::acc::from::LMSD65(float3(1, 1, 1)).x), kEps); + return abs(acc.x) / acc_white; + } + + if (energy_mode == NRG_TEST5_ENERGY_LMS_D65_ABS_SUM) { + return ComputeAbsSum(lms_d65) / kScalarWhiteUnits; + } + + return ComputeAbsSum(bt2020_linear) / kScalarWhiteUnits; +} + +float NRGTest7ScalarAccARaw( + float3 bt2020_linear, + float peak = 1.f) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; + float peak_ref = max(peak, kEps); + + float scalar_unit = NRGTest5ScalarInputUnit( + max(bt2020_linear, 0), + NRG_TEST5_ENERGY_ACC_A); + return scalar_unit * kScalarWhiteUnits; +} + +float3 NRGTest7SolveWhiteSpillByScalarAccA( + float3 bt2020_start, + float peak, + float target_scalar_raw, + out float scalar_out_raw) { + const float kEps = 1e-6f; + const int kIterations = 16; + const float kScalarWhiteUnits = 3.f; + float peak_ref = max(peak, kEps); + + float3 bt2020_white = peak_ref.xxx; + float scalar_start = NRGTest7ScalarAccARaw(bt2020_start, peak_ref); + float scalar_white = kScalarWhiteUnits * peak_ref; + float scalar_target = clamp(target_scalar_raw, scalar_start, scalar_white); + + if (scalar_target <= scalar_start + kEps) { + scalar_out_raw = scalar_start; + return bt2020_start; + } + if (scalar_target >= scalar_white - kEps) { + scalar_out_raw = scalar_white; + return bt2020_white; + } + + float lo = 0.f; + float hi = 1.f; + [unroll] + for (int i = 0; i < kIterations; ++i) { + float mid = 0.5f * (lo + hi); + float3 sample = lerp(bt2020_start, bt2020_white, mid); + float scalar_mid = NRGTest7ScalarAccARaw(sample, peak_ref); + if (scalar_mid < scalar_target) { + lo = mid; + } else { + hi = mid; + } + } + + float t = 0.5f * (lo + hi); + float3 out_bt2020 = lerp(bt2020_start, bt2020_white, t); + scalar_out_raw = NRGTest7ScalarAccARaw(out_bt2020, peak_ref); + return out_bt2020; +} + +float3 NRGTest7BlendChromaAndWhiteSpillNeutwoClipHueWall( + float3 bt2020_chroma, + float3 bt2020_chroma_max, + float peak, + float scalar_output_raw, + float scalar_chroma_max, + float start_ratio = 1.f, + float shape = 1.f) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; + float peak_ref = max(peak, kEps); + float scalar_white_raw = kScalarWhiteUnits * peak_ref; + + float scalar_start = scalar_chroma_max * saturate(start_ratio); + float scalar_overdrive = max(scalar_output_raw - scalar_start, 0.f); + float scalar_headroom = max(scalar_white_raw - scalar_start, kEps); + float scalar_overdrive_unit = scalar_overdrive / scalar_headroom; + + // Per-hue clip from wall capacity in ACC-A scalar units. + // low wall -> clip near 1 (faster white), high wall -> clip near 2 (slower white) + float clip_hue = 1.f + saturate(renodx::math::DivideSafe(scalar_chroma_max, max(scalar_white_raw, kEps), 0.f)); + + float white_mix = saturate(renodx::tonemap::Neutwo( + scalar_overdrive_unit, + 1.f, + clip_hue)); + if (abs(shape - 1.f) > 1e-6f) { + white_mix = pow(max(white_mix, 0.f), max(shape, 1e-6f)); + } + + float scalar_spill_raw; + float3 bt2020_spill = NRGTest7SolveWhiteSpillByScalarAccA( + bt2020_chroma_max, + peak_ref, + scalar_output_raw, + scalar_spill_raw); + return lerp(bt2020_chroma, bt2020_spill, white_mix); +} + +float3 NRGTest5BT2020( + float3 bt2020_linear, + float peak = 1.f, + int energy_mode = NRG_TEST5_ENERGY_ACC_A) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; // BT.2020 abs-sum: white@1 = 3, white@peak = 3*peak. + float peak_ref = max(peak, kEps); + + // Test5 keeps Test4's robust hue geometry, but allows alternate scalar energy drives. + float scalar_input_unit = NRGTest5ScalarInputUnit(bt2020_linear, energy_mode); + float scalar_output_unit = NRGTest4ScalarRushtonHenryToPeak(scalar_input_unit, peak_ref); + float scalar_output_raw = scalar_output_unit * kScalarWhiteUnits; + float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); + + float scalar_chroma_max = ComputeAbsSum(bt2020_chroma_max); + if (scalar_chroma_max <= kEps) { + // Degenerate hue: move on neutral axis by RH scalar percent. + return (scalar_output_raw / kScalarWhiteUnits).xxx; + } + + float scalar_white_raw = kScalarWhiteUnits * peak_ref; + float p = saturate(renodx::math::DivideSafe( + scalar_output_raw, + scalar_white_raw, + 0.f)); + + float p_wall = clamp(NRG_TEST5_P_WALL, 1e-4f, 0.9999f); + if (p <= p_wall) { + // Stage 1: black -> max chroma (at p_wall). + float chroma_t = saturate(renodx::math::DivideSafe(p, p_wall, 0.f)); + return bt2020_chroma_max * chroma_t; + } + + // Stage 2: max chroma -> white. Max chroma is only present at p == p_wall. + float white_t = saturate(renodx::math::DivideSafe( + p - p_wall, + 1.f - p_wall, + 0.f)); + return lerp(bt2020_chroma_max, peak_ref.xxx, white_t); +} + +float3 NRGTest5( + float3 bt709_linear, + float peak = 1.f, + int energy_mode = NRG_TEST5_ENERGY_ACC_A) { + float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); + float3 bt2020_out = NRGTest5BT2020(bt2020_linear, peak, energy_mode); + return renodx::color::bt709::from::BT2020(bt2020_out); +} + +float3 NRGTest6BT2020( + float3 bt2020_linear, + float peak = 1.f, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS, + int curve_mode = NRG_TEST6_CURVE_RH) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; // Virtual spill pressure only; final targets stay <= white JND. + float peak_ref = max(peak, kEps); + // Test4 geometry, but scalar is total JND from black->color normalized by black->peak. + float scalar_peak_raw = max( + NRGTest6JNDPeakZeroRaw(peak_ref.xxx, peak_ref, background_nits), + kEps); + float scalar_input_raw = NRGTest6JNDPeakZeroRaw(bt2020_linear, peak_ref, background_nits); + float scalar_input_unit = scalar_input_raw * peak_ref / scalar_peak_raw; + float scalar_output_unit = curve_mode == NRG_TEST6_CURVE_NR + ? renodx::tonemap::NakaRushton(scalar_input_unit, peak_ref, 0.18f, 0.18f, 1.f) + : NRGTest4ScalarRushtonHenryToPeak(scalar_input_unit, peak_ref); + float scalar_output_raw = scalar_output_unit * scalar_peak_raw / peak_ref; + float scalar_white_raw = scalar_peak_raw; + + float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); + float scalar_chroma_max = NRGTest6JNDPeakZeroRaw(bt2020_chroma_max, peak_ref, background_nits); + if (scalar_chroma_max <= kEps) { + float scalar_final_raw; + return SolveLineByJNDScalar( + 0, + peak_ref.xxx, + peak_ref, + scalar_output_raw, + scalar_final_raw, + background_nits); + } + + // Apply extra spill pressure in a virtual scalar domain, but remap the result + // back into the physically reachable JND interval [scalar_chroma_max, scalar_peak_raw]. + float scalar_headroom = max(scalar_peak_raw - scalar_chroma_max, 0.f); + if (scalar_headroom > kEps) { + float scalar_output_virtual = scalar_output_raw * kScalarWhiteUnits; + float scalar_overflow = max(scalar_output_virtual - scalar_chroma_max, 0.f); + if (scalar_overflow > kEps) { + float scalar_overflow_norm = saturate(renodx::math::DivideSafe( + scalar_overflow, + max(scalar_peak_raw * (kScalarWhiteUnits - 1.f), kEps), + 0.f)); + float scalar_spill_target = lerp(scalar_chroma_max, scalar_peak_raw, scalar_overflow_norm); + scalar_output_raw = max(scalar_output_raw, scalar_spill_target); + } + } + + float scalar_chroma = min(scalar_output_raw, scalar_chroma_max); + float scalar_chroma_raw; + float3 bt2020_chroma = SolveLineByJNDScalar( + 0, + bt2020_chroma_max, + peak_ref, + scalar_chroma, + scalar_chroma_raw, + background_nits); + + return BlendChromaAndWhiteSpillJND( + bt2020_chroma, + bt2020_chroma_max, + peak_ref, + scalar_output_raw, + scalar_chroma_max, + scalar_white_raw, + background_nits); +} + +float3 NRGTest6( + float3 bt709_linear, + float peak = 1.f, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS, + int curve_mode = NRG_TEST6_CURVE_RH) { + float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); + float3 bt2020_out = NRGTest6BT2020(bt2020_linear, peak, background_nits, curve_mode); + return renodx::color::bt709::from::BT2020(bt2020_out); +} + +float3 NRGTest7HueClipBT2020(float3 bt2020_linear, float peak = 1.f) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; + float peak_ref = max(peak, kEps); + + // ACC-A scalar drive -> Neutwo white curve. + float scalar_input_unit = NRGTest5ScalarInputUnit( + max(bt2020_linear, 0), + NRG_TEST5_ENERGY_ACC_A); + float scalar_output_unit = renodx::tonemap::Neutwo( + max(scalar_input_unit, 0.f), + peak_ref); + float scalar_output_raw = scalar_output_unit * kScalarWhiteUnits; + + // Max-hue anchor in BT.2020, then transition toward white. + float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); + float scalar_chroma_max = NRGTest7ScalarAccARaw(bt2020_chroma_max, peak_ref); + if (scalar_chroma_max <= kEps) { + float scalar_final_raw; + return NRGTest7SolveWhiteSpillByScalarAccA( + 0, + peak_ref, + scalar_output_raw, + scalar_final_raw); + } + + float scalar_chroma = min(scalar_output_raw, scalar_chroma_max); + float chroma_scale = renodx::math::DivideSafe( + scalar_chroma, + scalar_chroma_max, + 0.f); + float3 bt2020_chroma = bt2020_chroma_max * chroma_scale; + + return NRGTest7BlendChromaAndWhiteSpillNeutwoClipHueWall( + bt2020_chroma, + bt2020_chroma_max, + peak_ref, + scalar_output_raw, + scalar_chroma_max, + 1.f, + 1.f); +} + +float3 NRGTest7HueClip(float3 bt709_linear, float peak = 1.f) { + float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); + float3 bt2020_out = NRGTest7HueClipBT2020(bt2020_linear, peak); + return renodx::color::bt709::from::BT2020(bt2020_out); +} + +float3 BT709TEST7(float3 bt709_linear, + float display_peak = 1.f, + int mode = NRG_BLEACH_MODEL_SCALAR) { + if (mode == NRG_BLEACH_MODEL_PER_CONE) { + return NeutwoBT709WhiteForEnergy(bt709_linear, display_peak); + } + return FastInputLMSEnergyGray(bt709_linear); +} + +float3 BT2020TEST7(float3 bt2020_linear, + float display_peak_bt2020 = 1.f, + int mode = NRG_BLEACH_MODEL_SCALAR) { + float3 bt709 = renodx::color::bt709::from::BT2020(bt2020_linear); + float3 out_bt709 = BT709TEST7(bt709, display_peak_bt2020, mode); + return renodx::color::bt2020::from::BT709(out_bt709); +} + +} // namespace nrg +} // namespace tonemap +} // namespace renodx + +#endif // RENODX_SHADERS_TONEMAP_NRG_HLSL_ diff --git a/src/games/elitedangerous/tonemap/psychov25/stockman.hlsli b/src/games/elitedangerous/tonemap/psychov25/stockman.hlsli new file mode 100644 index 000000000..283cf2c5b --- /dev/null +++ b/src/games/elitedangerous/tonemap/psychov25/stockman.hlsli @@ -0,0 +1,112 @@ +#ifndef SRC_SHADERS_COLOR_STOCKMAN_HLSL_ +#define SRC_SHADERS_COLOR_STOCKMAN_HLSL_ + +#include "../../common.hlsli" + +// Deprecated (use renodx::color::lms::* directly) + +namespace renodx { +namespace color { +namespace bt709 { +namespace from { + +float3 StockmanDKL(float3 dkl) { + // Modified Stockman & Sharpe for LCD LED + float3x3 XYZ_TO_LMS_WUERGER_2020 = float3x3( + 0.187596268556126, 0.585168649077728, -0.026384263306304, + -0.133397430663221, 0.405505777260049, 0.034502127690364, + 0.000244379021663, -0.000542995890619, 0.019406849066323); + + // Manually recomputed from CIE 1931 XYZ 1nm to Stockman 2deg 1nm 8dp with MB2 Weights + float3x3 XYZ_TO_LMS_2006 = float3x3( + 0.185082982238733f, 0.584081279463687f, -0.0240722415044404f, + -0.134433056469973f, 0.405752392775348f, 0.0358252602217631f, + 0.000789456671966863f, -0.000912281325916184f, 0.0198490812339463f); + + float3x3 XYZ_FROM_LMS = renodx::math::Invert3x3(XYZ_TO_LMS_2006); + + // CIE 1931 2 degree standard observer + float2 WHITE_POINT_D65 = float2(0.31272, 0.32903); + float3 D65_XYZ = renodx::color::xyz::from::xyY(float3(WHITE_POINT_D65, 1.f)); + float3 LMS_WHITE = mul(XYZ_TO_LMS_2006, D65_XYZ); + + float mc1 = LMS_WHITE.x / LMS_WHITE.y; + float mc2 = (LMS_WHITE.x + LMS_WHITE.y) / LMS_WHITE.z; + + // actual ACC color space (DKL-like / ACC) + float3x3 LMS_TO_DKL_D65 = float3x3( + 1, 1, 0, + 1, -mc1, 0, + -1, -1, mc2); + + float3x3 LMS_FROM_DKL_D65 = renodx::math::Invert3x3(LMS_TO_DKL_D65); + + float3x3 RGB_TO_DKL_D65 = mul(LMS_TO_DKL_D65, XYZ_TO_LMS_2006); + float3x3 DKL_D65_TO_RGB = renodx::math::Invert3x3(RGB_TO_DKL_D65); + + float3 lms_color = mul(LMS_FROM_DKL_D65, dkl); + + float3 lms_background = mul(XYZ_TO_LMS_2006, renodx::color::xyz::from::xyY(float3(WHITE_POINT_D65, 1.00f))); + + lms_background = 0; // skip for now + float3 lms_final = lms_color + lms_background; + + float3 xyz = mul(XYZ_FROM_LMS, lms_final); + + float3 bt709 = renodx::color::bt709::from::XYZ(xyz); + return bt709; +} +} // namespace from +} // namespace bt709 + +namespace stockmandkl { +namespace from { +float3 BT709(float3 bt709) { + // Modified Stockman & Sharpe for LCD LED + float3x3 XYZ_TO_LMS_WUERGER_2020 = float3x3( + 0.187596268556126, 0.585168649077728, -0.026384263306304, + -0.133397430663221, 0.405505777260049, 0.034502127690364, + 0.000244379021663, -0.000542995890619, 0.019406849066323); + + // Manually recomputed from CIE 1931 XYZ 1nm to Stockman 2deg 1nm 8dp with MB2 Weights + float3x3 XYZ_TO_LMS_2006 = float3x3( + 0.185082982238733f, 0.584081279463687f, -0.0240722415044404f, + -0.134433056469973f, 0.405752392775348f, 0.0358252602217631f, + 0.000789456671966863f, -0.000912281325916184f, 0.0198490812339463f); + + float3x3 XYZ_FROM_LMS = renodx::math::Invert3x3(XYZ_TO_LMS_2006); + + // CIE 1931 2 degree standard observer + float2 WHITE_POINT_D65 = float2(0.31272, 0.32903); + float3 D65_XYZ = renodx::color::xyz::from::xyY(float3(WHITE_POINT_D65, 1.f)); + float3 LMS_WHITE = mul(XYZ_TO_LMS_2006, D65_XYZ); + + float mc1 = LMS_WHITE.x / LMS_WHITE.y; + float mc2 = (LMS_WHITE.x + LMS_WHITE.y) / LMS_WHITE.z; + + // actual ACC color space (DKL-like / ACC) + float3x3 LMS_TO_DKL_D65 = float3x3( + 1, 1, 0, + 1, -mc1, 0, + -1, -1, mc2); + + float3x3 LMS_FROM_DKL_D65 = renodx::math::Invert3x3(LMS_TO_DKL_D65); + float3 xyz = renodx::color::xyz::from::BT709(bt709); + float3 lms_input = mul(XYZ_TO_LMS_2006, xyz); + float3 dkl_input = mul(LMS_TO_DKL_D65, lms_input); + + float3 lms_background = mul(XYZ_TO_LMS_2006, renodx::color::xyz::from::xyY(float3(WHITE_POINT_D65, 1.00f))); + + lms_background = 0; // skip for now + float3 delta = lms_input - lms_background; + + float3 dkl = mul(LMS_TO_DKL_D65, delta); + + return dkl; +} +} // namespace from +} // namespace stockmandkl + +} // namespace color +} // namespace renodx +#endif // SRC_SHADERS_COLOR_STOCKMAN_HLSL_ \ No newline at end of file diff --git a/src/games/elitedangerous/tonemap/psychov25/test25.hlsli b/src/games/elitedangerous/tonemap/psychov25/test25.hlsli new file mode 100644 index 000000000..ff5d62b3a --- /dev/null +++ b/src/games/elitedangerous/tonemap/psychov25/test25.hlsli @@ -0,0 +1,4085 @@ +#ifndef RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ +#define RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ + +#include "../../common.hlsli" +#include "./nrg.hlsli" + +/* + * Copyright (C) 2026 Carlos Lopez + * SPDX-License-Identifier: MIT + */ + +namespace renodx { +namespace tonemap { +namespace psychov { + +// Psycho25 current implementation +// ------------------------------- +// 1. Test24 grading and adaptive-MB purity are retained. +// 2. Anchor-matched per-cone contrast uses sign-preserving powers, retaining +// signed cone ratios through authored hue and device-hull work. The +// compression-derived power encodes a cone-response state whose adapted +// origin is exactly one. Encoded-response power acts before the rational +// shoulder. +// 3. Per-channel compression defines the raw adaptive-MB hue shift. +// 4. Graph hue authoring uses the numerical sextant peak-search and local +// graph-inversion solve at 50% amplitude. Fast60 is the lower-cost direct +// angular midpoint between the source and raw per-channel-compressed +// adaptive-MB directions. Cone-axis pins remain zeros because the raw +// per-channel hue shift itself is zero on those axes. +// 5. The actual-peak compressed adaptive-MB radius and carried achromatic +// scale are retained while only the direction is changed. +// 6. With gamut compression disabled, the retained per-cone LMS rolloff is the +// output shoulder and supplies the compression path toward adapted white. +// 7. With either target-plane class enabled, per-cone output compression is +// bypassed. +// The actual-peak per-cone result supplies adaptive-MB magnitude and radius. +// A separate compressed direction, whose neutral endpoint is scaled by +// `guidance_peak_scale`, supplies the hue trajectory guide. +// Normalization discards carried scale while preserving the physical radius +// and guided direction without applying either per-cone curve as the final +// output compressor. +// 8. The trajectory-guided adaptive-MB direction supplies one device-hull +// ray. Primary enforcement uses the selected target RGB lower planes; peak +// enforcement uses its upper planes. The two plane classes are independent. +// With peak enforcement disabled, output follows the authored scalar Yf +// after any requested primary correction. Linear BT.709 return values may +// be negative when they represent valid colors inside a wider selected +// target. +// 9. Selected-target lower-plane feasibility is solved against a same-hue +// reference radius no smaller than the current physical trajectory or its +// uncompressed post-contrast source. A C1 radial shoulder begins at 90% of +// the selected-target lower-plane boundary instead of activating only after +// a channel becomes negative. Reusing that scale over the outer trajectory +// preserves its inward-to-white gradient instead of projecting every +// outside point onto the same gamut boundary. The scale releases smoothly +// toward the physical path near neutral so blue can keep gaining channel +// value without turning gray, with a smooth current-trajectory containment +// cap for signed inputs. This is a direction constraint, not a second +// output compression curve. +// 10. Reference and Reduced Max-White smoothly turn the authored direction +// back toward the pre-contrast source direction as the physical radius +// collapses. They do not retain a nonzero radius: chromatic highlights can +// become lighter and converge on white without first rotating through an +// unrelated hue. +// 11. Reference2 is an experimental Graph-authoritative variant of Reference. +// It retains the six-section direction without the post-Graph source- +// direction recovery or Reference's same-Yf radial contraction. After the +// scalar upper-plane shoulder, lower-plane pressure lifts the complete +// selected-target RGB result smoothly toward peak D65 white. Quadratic +// pressure moves an outside trajectory progressively inward instead of +// flattening it onto a target wall without creating an aggressive Yf hump +// at first contact. The result is then reprojected onto the Graph- +// authored adaptive-MB hue while retaining its raised Yf and reduced radius. +// The physical per-cone radius still converges to peak D65 white. +// 12. Linear MB Pullback is a diagnostic lower-plane mode. It retains the +// Graph/Fast60-authored adaptive-MB direction and actual-peak radius while +// feasible, then linearly reduces only that radius to the first selected- +// target lower-plane intersection. It has no custom reference radius, +// shoulder, neutral release, or source-direction recovery. +// 13. Target RGB Clip is a literal comparison path. It runs the same physical +// per-cone and authored-hue result without target-hull mapping, transforms +// it to the selected linear BT.709 or BT.2020 RGB space, and clamps each +// component directly to [0, peak]. It adds no sectional gamut curve. +// 14. Experimental post-compression is independent of hull selection. Modes +// 1-8 branch from the common post-contrast LMS state before the physical +// per-cone shoulder, Graph/Fast60 hue authoring, or target-hull solve. +// Direct target-RGB per-channel and max-channel shoulders can be compared +// with adaptive-MB hard pullback, adaptive soft compression, RenoDX fixed- +// D65 soft compression, and source-MB-direction variants. Source BT.709 +// Residual retains the default coupled path and replaces only its final +// linear-BT.709 residual direction. PsychoV17 Gamut instead retains +// Test25's physical per-cone shoulder and authored hue, bypasses Test25's +// coupled target-hull solve, then applies PsychoV17's final adaptive- +// relative weighted-LMS target-primary compression. PsychoV17 Gamut + +// Neutwo Max retains that physical/hue trajectory for target-RGB direction, +// derives magnitude from the unbounded post-contrast signal after the same +// gamut map, then uses one anchor-normalized max-channel Neutwo peak map. +// PsychoV17 +// Gamut + NRG White instead retains the completed output's ACC-A scalar +// metric while moving an over-peak selected-target RGB result from its hue +// wall toward peak D65 white. None of these options changes the default +// coupled path. These remain comparison probes rather than candidate +// device-volume mappings: common-scale max-channel modes can terminate on +// a colored wall. +// 15. Sectional White Volume is an experimental coupled-hull alternative. It +// retains the physical per-cone Yf and Graph/Fast60 six-section direction, +// measures their selected-target RGB displacement from the same-Yf D65 +// axis, and applies one globally smooth L8 cube-occupancy response. Lower +// primary and upper peak planes participate in the same cross-sectional +// solve. There is no separate hue-wall handoff, white-spill pass, or final +// component clamp. The inherited physical per-cone endpoint still requires +// every positive hue trajectory to converge to peak D65 white. +// 16. Reference3 is an experimental target-hue-triangle volume map. The +// selected linear RGB cube is decomposed exactly into one triangle per hue: +// black, the max/min target-channel hue-rim point, and peak D65 white. +// Physical per-cone Yf and Graph/Fast60 direction supply the preferred +// point before legacy lower/upper hull passes. Smooth positive barycentric +// weights place an outside point inside its exact target triangle, while +// quadratic lower/upper pressure moves increasingly invalid points toward +// white. Active cube-edge changes come only from target geometry; there is +// no authored hue- or level-segment handoff. +// +// 17. Canonical Cylinder is an experimental star-volume map. It normalizes +// authored adaptive-MB radius by the exact selected-target six-plane radial +// support at each hue/Yf, making every target a unit q-cylinder. Outside +// occupancy is passed through a pivot/contrast/generalized-Neutwo pressure +// response and split between inward q contraction and upward motion toward +// peak D65 white. The target support is re-evaluated at the raised Yf before +// reconstructing the final radius. In-gamut q <= 1 points are exact identity. +// 18. Adaptive Contrast Fit is an experimental post-ideal lost-contrast fit. +// It first completes Test25's ordinary physical/MIDPOINT result, then fits +// that point to the exact selected-target six-plane adaptive-MB radial +// support at the same physical Yf. Lost adaptive-MB radius contributes only +// above the adapted Yf, while genuine lost achromatic Yf contributes +// separately. Their bounded pressure advances one later state on Test25's +// own per-cone/MIDPOINT trajectory, then reapplies the exact target fit. No +// straight target-RGB interpolation to white is used. +// +// Device-hull implementation: +// Peak and RGB-gamut constraints are one device-hull problem. For normalized +// BT.709 output, the complete target is the cube 0 <= R,G,B <= 1, not a +// per-channel move toward white followed by an unrelated gamut constraint. +// With both plane classes enabled, the gamut-active branch evaluates this full +// cube along the numerically solved adaptive-MB trajectory. White is one +// possible intermediate in-hull result, but peak D65 white is the required +// endpoint of every positive hue trajectory. A hue may travel along cube faces +// while clipping, but it must not terminate on a colored face. The restored +// source record and longer-term hull plan +// below distinguish this ray solve from a future sectional optimization over +// multiple candidate points. +// In wide-target mode, the result remains represented as linear BT.709 until +// the caller converts it for output. Negative BT.709 components are therefore +// valid when the represented color is inside the selected wider target. + +static const float PSYCHO25_EPSILON = 1e-6f; +static const float PSYCHO25_PI = 3.14159265358979323846f; +static const float PSYCHO25_TWO_PI = 6.2831853071795864769f; +static const float PSYCHO25_LARGE = 1e20f; +static const float PSYCHO25_MAX_FINITE_INPUT = 65504.f; +static const float PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE = 0.9f; +static const float PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION = 0.75f; +static const float PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON = 1e-5f; +static const float PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER = 256.f; +static const float PSYCHO25_SECTIONAL_VOLUME_POWER = 8.f; +static const float PSYCHO25_WHITE_LIFT_PRESSURE_EPSILON = 1e-7f; + +// Auto-compression reference. +// Simultaneous luminance dynamic range is stimulus- and method-dependent. +// Published values considered for the automatic compression reference: +// - Kunkel & Reinhard, APGV 2010, doi:10.1145/1836248.1836251: +// ~3.7 log10 units under their adapted test conditions. +// - Jiang & Fairchild, JIST 2021, +// doi:10.2352/J.ImagingSci.Technol.2021.65.5.050401: +// direct bright/dark simultaneous measurements on an Apple Pro Display +// XDR setup reported ~3.3 log10 units for the average observer and +// 3.47 log10 units for OBS1 at 1600 cd/m^2, 3.4 degree stimulus size. +// Their spatial-frequency fit reports DRmax values of 3.24 log10 at +// 452 cd/m^2 and 3.40 log10 at 1600 cd/m^2. The display apparatus used +// diffuse white = 50 cd/m^2 and peak luminance = 1600 cd/m^2. +// +// Default choice: +// Kunkel/Reinhard's 3.7 value is the conservative reference. A larger +// reference range increases auto h on low-headroom displays, reducing the +// symmetric curve's OFF/shadow-side bending. Jiang/Fairchild's average is a +// possible direct-display, glare-inclusive alternative. +// +// Model choice: +// For a neutral static curve, the adapted/background state is treated as the +// log midpoint of the selected total range. Half of the log range is above +// adaptation and half below. This is a neutral log-domain prior, not a claim +// that biological ON/OFF pathways are exactly symmetric. +// +// For the slope-normalized compression below, the deep OFF-side slope ratio is: +// S_shadow / contrast = 1 / (1 - pow(anchor_out / peak, h)) +// Auto compression solves: +// h = (reference_range_log10 / 2) / log10(peak / anchor_out) +// which is equivalent to choosing: +// pow(anchor_out / peak, h) = pow(10, -(reference_range_log10 / 2)) +// The implied OFF-side slope ratio is therefore derived from the selected +// reference range rather than from an independent decimal tolerance. +static const float PSYCHO25_REFERENCE_SIMULTANEOUS_RANGE_LOG10 = 3.7f; +static const float PSYCHO25_REFERENCE_CENTERED_RANGE_SIDE_COUNT = 2.f; +// Target-relative neutral Yf endpoint for target-plane hue guidance. Scale 1 +// exactly matches the regular physical per-channel endpoint. +static const float PSYCHO25_MIN_GUIDANCE_PEAK_SCALE = 1.f; +static const float PSYCHO25_DEFAULT_GUIDANCE_PEAK_SCALE = 1.f; +static const float PSYCHO25_MIN_AUTO_COMPRESSION = 1.f; +static const float PSYCHO25_MIN_MANUAL_COMPRESSION = 1e-6f; +static const float PSYCHO25_AUTO_COMPRESSION_SENTINEL = 0.f; +static const float PSYCHO25_UPPER_PLANE_SHOULDER_POWER_MATCH_COMPRESSION = 0.f; + +// RenoDX v4 grading masks are applied to scalar Yf rather than independently +// to L, M, and S. This keeps the adapted anchor fixed and prevents the +// highlight/shadow controls from rotating adaptive-MB hue. +static const float PSYCHO25_HIGHLIGHT_GRADE_REFERENCE_WHITE = 1.f; +static const float PSYCHO25_SHADOW_GRADE_RANGE_STOPS = 4.f; + +// Numerical Graph searches each cone-axis-bounded interval and inverts the +// transformed hue field. Fast60 bypasses these constants and searches. +static const uint PSYCHO25_HUE_PEAK_SCAN_INTERVALS = 6u; +static const uint PSYCHO25_HUE_PEAK_REFINE_ITERATIONS = 12u; +static const uint PSYCHO25_HUE_INVERSE_BRACKET_INTERVALS = 16u; +static const uint PSYCHO25_HUE_INVERSE_ITERATIONS = 18u; +static const float PSYCHO25_HUE_REVERSAL_AXIS_SLOPE_LIMIT = -6.f; +static const float PSYCHO25_HUE_ORDER_DERIVATIVE_PROBE_DIVISOR = 64.f; +static const float PSYCHO25_HUE_ORDER_SAFETY = 0.9f; + +static const float PSYCHO25_HUE_AMPLITUDE = 0.5f; +static const int PSYCHO25_HUE_METHOD_GRAPH = 0; +static const int PSYCHO25_HUE_METHOD_FAST_60 = 1; +static const int PSYCHO25_HULL_METHOD_REFERENCE_SCALE = 0; +static const int PSYCHO25_HULL_METHOD_REDUCED_MAX_WHITE = 1; +static const int PSYCHO25_HULL_METHOD_LINEAR_MB_PULLBACK = 2; +static const int PSYCHO25_HULL_METHOD_TARGET_RGB_CLIP = 3; +static const int PSYCHO25_HULL_METHOD_SECTIONAL_WHITE_VOLUME = 4; +static const int PSYCHO25_HULL_METHOD_REFERENCE2 = 5; +static const int PSYCHO25_HULL_METHOD_REFERENCE3 = 6; +static const int PSYCHO25_HULL_METHOD_CANONICAL_CYLINDER = 7; +static const int PSYCHO25_HULL_METHOD_CANONICAL_YF_CONE = 8; +// Canonical-cylinder experimental defaults. The target RGB cube is reduced to +// q = rho / rho_max(theta, Yf); outside pressure is then redirected both +// inward in q and upward toward peak D65 white before converting back through +// the exact target radial support at the raised Yf. +static const float PSYCHO25_CANONICAL_CYLINDER_DEFAULT_PIVOT = 0.45f; +static const float PSYCHO25_CANONICAL_CYLINDER_DEFAULT_CONTRAST = 1.4f; +static const float PSYCHO25_CANONICAL_CYLINDER_DEFAULT_H = 2.f; +static const float PSYCHO25_CANONICAL_CYLINDER_DEFAULT_TRADE = 0.5f; +// Yf-cone variant: exponent controlling how quickly gamut pressure is allowed +// to become whiteward/achromatic motion. k=2 gives 1% whiteward pressure at +// 10% of target peak, 25% at 50%, and 81% at 90%. +static const float PSYCHO25_CANONICAL_YF_CONE_DEFAULT_BIAS_POWER = 2.f; +static const int PSYCHO25_POST_COMPRESSION_NONE = 0; +static const int PSYCHO25_POST_COMPRESSION_DIRECT = 1; +static const int PSYCHO25_POST_COMPRESSION_PER_CHANNEL = 2; +static const int PSYCHO25_POST_COMPRESSION_MAX_CHANNEL = 3; +static const int PSYCHO25_POST_COMPRESSION_ADAPTIVE_MB_HARD_MAX = 4; +static const int PSYCHO25_POST_COMPRESSION_ADAPTIVE_MB_SOFT_MAX = 5; +static const int PSYCHO25_POST_COMPRESSION_FIXED_D65_SOFT_MAX = 6; +static const int PSYCHO25_POST_COMPRESSION_SOURCE_MB_PER_CHANNEL = 7; +static const int PSYCHO25_POST_COMPRESSION_SOURCE_MB_SOFT_MAX = 8; +static const int PSYCHO25_POST_COMPRESSION_SOURCE_BT709_RESIDUAL = 9; +static const int PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT = 10; +static const int PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NEUTWO_MAX = 11; +static const int PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NRG_WHITE = 12; +static const int PSYCHO25_POST_COMPRESSION_ADAPTIVE_CONTRAST_FIT = 13; +static const int PSYCHO25_UPPER_HULL_PIVOT_BLACK = 0; +static const int PSYCHO25_UPPER_HULL_PIVOT_ADAPTED_OUTPUT = 1; +static const float PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY = 0.8f; +static const float PSYCHO25_REDUCED_MAX_WHITE_SOURCE_DIRECTION_OCCUPANCY = 1.f; +static const float PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION = 0.25f; +static const float PSYCHO25_SOURCE_DIRECTION_BLEND_POWER = 2.f; +static const int PSYCHO25_GAMUT_ENFORCEMENT_NONE = 0; +static const int PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES = 1; +static const int PSYCHO25_GAMUT_ENFORCEMENT_PEAK = 2; +static const int PSYCHO25_GAMUT_ENFORCEMENT_FULL = + PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES + | PSYCHO25_GAMUT_ENFORCEMENT_PEAK; +static const int PSYCHO25_GAMUT_ENFORCEMENT_LEGACY = -1; +static const int PSYCHO25_INPUT_PRESTEP_NONE = 0; +static const int PSYCHO25_INPUT_PRESTEP_POSITIVE_LMS = 1; +static const int PSYCHO25_INPUT_PRESTEP_CIE1702 = 2; +static const int PSYCHO25_INPUT_PRESTEP_CIE1702_ABSOLUTE_YF = 3; +static const int PSYCHO25_OBSERVER_GAMUT_NONE = 0; +static const int PSYCHO25_OBSERVER_GAMUT_CIE1702 = 1; + +struct Psycho25HueSection { + float start; + float end; + float midpoint; + float source_unwrapped; + uint index; +}; + +struct Psycho25HueGeometry { + float peak_angle; + float peak_shift; + float axis_slope; + float maximum_ordered_amplitude; + uint active; +}; + +struct Psycho25ConeResponseState { + float3 encoded_response; + float3 compression_exponent; + float3 input_response_exponent; + float3 encoded_peak_offset; +}; + +struct Psycho25ConeResponseParameters { + float3 anchor_out; + float3 compression_exponent; + float3 input_response_exponent; + float3 encoded_peak_offset; + float encoded_response_power; + float inverse_compression_power; +}; + +struct Psycho25HueEvaluationContext { + Psycho25ConeResponseParameters guidance_cone_response; + float3 current_adaptive_state_lms; + float3 anchor_in; + float3 anchor_out; + float3 guidance_lms_peak; + float2 adapted_neutral_mb; + float source_radius; + float source_target_yf; + float contrast_power; + int observer_gamut_mode; +}; + +struct Psycho25AdaptiveMBTrajectory { + float3 authored_mb; + uint hue_applied; +}; + +float psycho25_Cross2(float2 a, float2 b) { + return a.x * b.y - a.y * b.x; +} + +float psycho25_PositiveHueAngle(float angle) { + angle -= PSYCHO25_TWO_PI * floor(angle / PSYCHO25_TWO_PI); + return angle < 0.f ? angle + PSYCHO25_TWO_PI : angle; +} + +float psycho25_SignedYfFromLMS(float3 lms) { + float3 weighted_lms = + renodx::color::macleod_boynton::WeighLMS(lms); + return weighted_lms.x + weighted_lms.y; +} + +float psycho25_YfFromLMS(float3 lms) { + return max( + psycho25_SignedYfFromLMS(lms), + PSYCHO25_EPSILON); +} + +// Map a signed LMS input onto its D65-relative CIE 170-2 hue ray while +// retaining the absolute-L/M Yf magnitude already used by Test25 grading. +// This is not radiant energy: Yf is the weighted L+M coordinate and excludes +// S. Signed L+M supplies the source ray when defined; its absolute-LMS ray is +// the fallback when the signed denominator is degenerate. +float3 psycho25_AlignInputToCIE1702Hue(float3 lms_input) { + float3 lms_weighted = + renodx::color::macleod_boynton::WeighLMS(lms_input); + float3 lms_weighted_absolute = abs(lms_weighted); + float absolute_yf = + lms_weighted_absolute.x + lms_weighted_absolute.y; + if (!(absolute_yf > PSYCHO25_EPSILON)) { + return 0.f.xxx; + } + + float signed_yf = lms_weighted.x + lms_weighted.y; + float2 source_ls = abs(signed_yf) > PSYCHO25_EPSILON + ? float2(lms_weighted.x, lms_weighted.z) / signed_yf + : float2(lms_weighted_absolute.x, lms_weighted_absolute.z) + / absolute_yf; + float2 white_ls = renodx::color::gamut::CIE1702WhiteChromaticity(); + float2 direction = source_ls - white_ls; + float t_final = 1.f; + if (dot(direction, direction) + > renodx::color::gamut::MB_NEAR_WHITE_EPSILON) { + t_final = min( + 1.f, + renodx::color::gamut::RayExitTCIE1702PreciseD(direction)); + } + + return renodx::color::macleod_boynton::UnweighLMS( + renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton( + white_ls + t_final * direction, + absolute_yf)); +} + +float3 psycho25_ApplyInputPreStep(float3 lms_input, int input_pre_step) { + if (input_pre_step == PSYCHO25_INPUT_PRESTEP_POSITIVE_LMS) { + return max(lms_input, 0.f.xxx); + } + if (input_pre_step == PSYCHO25_INPUT_PRESTEP_CIE1702) { + return renodx::color::gamut::GamutCompressLMSPrecise(lms_input); + } + if (input_pre_step == PSYCHO25_INPUT_PRESTEP_CIE1702_ABSOLUTE_YF) { + return psycho25_AlignInputToCIE1702Hue(lms_input); + } + return lms_input; +} + +float3 psycho25_ApplyObserverGamutCompression( + float3 contrast_lms, + int observer_gamut_mode) { + if (observer_gamut_mode == PSYCHO25_OBSERVER_GAMUT_CIE1702) { + return renodx::color::gamut::GamutCompressLMSPrecise(contrast_lms); + } + return contrast_lms; +} + +// Apply the optional fixed-observer constraint to actual LMS immediately +// after independent per-cone contrast. Graph candidates use this same stage, +// so an observer-invalid candidate cannot define the authored hue field. +float3 psycho25_ApplyContrastResponse( + float3 lms_input, + float3 anchor_in, + float3 anchor_out, + float contrast_power, + int observer_gamut_mode) { + float3 contrast_lms = anchor_out + * renodx::math::SignPow( + lms_input / anchor_in, + contrast_power); + return psycho25_ApplyObserverGamutCompression( + contrast_lms, + observer_gamut_mode); +} + +float psycho25_GradeQuinticUnitRamp(float t) { + t = saturate(t); + return t * t * t * (t * (t * 6.f - 15.f) + 10.f); +} + +Psycho25ConeResponseParameters psycho25_PrepareConeResponseParameters( + float3 anchor_out, + float3 lms_peak, + float contrast_power, + float compression_power, + float encoded_response_power) { + Psycho25ConeResponseParameters parameters; + parameters.anchor_out = max(anchor_out, PSYCHO25_EPSILON.xxx); + float3 anchor_over_peak = parameters.anchor_out / lms_peak; + float3 anchor_peak_power = pow( + anchor_over_peak, + compression_power); + float3 compression_slope_norm = 1.f - anchor_peak_power; + parameters.compression_exponent = compression_power + / compression_slope_norm; + parameters.encoded_response_power = max( + encoded_response_power, + PSYCHO25_EPSILON); + parameters.input_response_exponent = max( + contrast_power, + PSYCHO25_EPSILON) + * parameters.compression_exponent + * parameters.encoded_response_power; + // (peak / anchor)^h - 1 == 1 / (anchor / peak)^h - 1. + parameters.encoded_peak_offset = rcp(anchor_peak_power) - 1.f; + parameters.inverse_compression_power = rcp(compression_power); + return parameters; +} + +// Scalar RenoDX v4 highlight grade. +// highlights > 1 increases highlights; highlights < 1 reduces them. +// The adapted anchor is an exact fixed point. +float psycho25_HighlightsScalarV4( + float x, + float highlights, + float adapted_anchor_yf) { + if (highlights == 1.f) return x; + + float t = 0.f; + if (x > adapted_anchor_yf) { + float reference_range_log2 = log2( + PSYCHO25_HIGHLIGHT_GRADE_REFERENCE_WHITE + / max(adapted_anchor_yf, PSYCHO25_EPSILON)); + t = saturate( + log2(x / max(adapted_anchor_yf, PSYCHO25_EPSILON)) + / max(reference_range_log2, PSYCHO25_EPSILON)); + } + t = psycho25_GradeQuinticUnitRamp(t); + + float ratio = max( + x / max(adapted_anchor_yf, PSYCHO25_EPSILON), + PSYCHO25_EPSILON); + if (highlights > 1.f) { + return lerp( + x, + adapted_anchor_yf * pow(ratio, highlights), + t); + } + + float b = adapted_anchor_yf * pow(ratio, 2.f - highlights); + return renodx::math::DivideSafe(x * x, lerp(x, b, t), x); +} + +// Scalar RenoDX v4 shadow grade. +// shadows > 1 brightens shadows; shadows < 1 darkens them. +// The adapted anchor is an exact fixed point; the mask reaches full strength +// at the deep-shadow reference. +float psycho25_ShadowsScalarV4( + float x, + float shadows, + float adapted_anchor_yf) { + if (shadows == 1.f) return x; + + float ratio = max( + renodx::math::DivideSafe(x, adapted_anchor_yf, 0.f), + 0.f); + float base_term = x * adapted_anchor_yf; + float base_scale = renodx::math::DivideSafe(base_term, ratio, 0.f); + float shadow_floor = + adapted_anchor_yf * exp2(-PSYCHO25_SHADOW_GRADE_RANGE_STOPS); + + float t = 1.f; + if (x > shadow_floor) { + t = saturate( + log2(x / max(adapted_anchor_yf, PSYCHO25_EPSILON)) + / log2( + shadow_floor + / max(adapted_anchor_yf, PSYCHO25_EPSILON))); + } + t = psycho25_GradeQuinticUnitRamp(t); + + if (shadows > 1.f) { + float raised = x * (1.f + renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO25_EPSILON), shadows), 0.f)); + float reference = x * (1.f + base_scale); + return x + (raised - reference) * t; + } + + float lowered = x * (1.f - renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO25_EPSILON), 2.f - shadows), 0.f)); + float reference = x * (1.f - base_scale); + return x + (lowered - reference) * t; +} + +float psycho25_AutoCompressionFromCenteredReferenceRange( + float anchor_out_yf, + float peak_yf) { + float peak_over_anchor = peak_yf / anchor_out_yf; + + float reference_one_side_range_log10 = + PSYCHO25_REFERENCE_SIMULTANEOUS_RANGE_LOG10 + / PSYCHO25_REFERENCE_CENTERED_RANGE_SIDE_COUNT; + float actual_above_adaptation_range_log10 = log10(peak_over_anchor); + return max( + reference_one_side_range_log10 + / actual_above_adaptation_range_log10, + PSYCHO25_MIN_AUTO_COMPRESSION); +} + +float psycho25_ResolveGuidancePeakYf( + float target_peak_yf, + float guidance_peak_scale) { + return target_peak_yf + * max(guidance_peak_scale, PSYCHO25_MIN_GUIDANCE_PEAK_SCALE); +} + +float3 psycho25_ToAdaptiveRelativeWeightedLMS( + float3 lms_input, + float3 current_adaptive_state_lms) { + return renodx::math::DivideSafe( + renodx::color::macleod_boynton::WeighLMS(lms_input), + current_adaptive_state_lms, + 0.f.xxx); +} + +float3 psycho25_FromAdaptiveRelativeWeightedLMS( + float3 lms_weighted_relative, + float3 current_adaptive_state_lms) { + return lms_weighted_relative + * max(current_adaptive_state_lms, PSYCHO25_EPSILON.xxx); +} + +float3 psycho25_LMSFromAdaptiveMB( + float3 mb, + float3 current_adaptive_state_lms) { + float3 relative_weighted = + renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton(mb); + return renodx::color::macleod_boynton::UnweighLMS( + psycho25_FromAdaptiveRelativeWeightedLMS( + relative_weighted, + current_adaptive_state_lms)); +} + +float3 psycho25_ApplyAdaptiveMBPurity( + float3 lms_input, + float3 adaptive_neutral_lms, + float purity_delta) { + if (abs(purity_delta - 1.f) <= 1e-5f) return lms_input; + + float3 relative_weighted = + psycho25_ToAdaptiveRelativeWeightedLMS( + lms_input, + adaptive_neutral_lms); + float3 mb = + renodx::color::macleod_boynton::from::WeightedLMS( + relative_weighted); + float3 mb_neutral = + renodx::color::macleod_boynton::from::LMS(1.f.xxx); + float2 mb_scaled_xy = lerp(mb_neutral.xy, mb.xy, purity_delta); + float3 relative_weighted_out = + renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton( + float3(mb_scaled_xy, mb.z)); + return renodx::color::macleod_boynton::UnweighLMS( + psycho25_FromAdaptiveRelativeWeightedLMS( + relative_weighted_out, + adaptive_neutral_lms)); +} + +float2 psycho25_AdaptiveMBDirection( + float3 lms_input, + float3 current_adaptive_state_lms, + float2 adapted_neutral_mb) { + float3 relative_weighted = + psycho25_ToAdaptiveRelativeWeightedLMS( + lms_input, + current_adaptive_state_lms); + float3 mb = + renodx::color::macleod_boynton::from::WeightedLMS( + relative_weighted); + float2 offset = mb.xy - adapted_neutral_mb; + float radius2 = dot(offset, offset); + if (radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) return 0.f.xx; + return offset * rsqrt(radius2); +} + +float2 psycho25_IsolatedConeDisplacementAxis( + float3 current_adaptive_state_lms, + float2 adapted_neutral_mb, + uint cone_index) { + float3 displaced_lms = current_adaptive_state_lms; + if (cone_index == 0u) { + displaced_lms.x *= 2.f; + } else if (cone_index == 1u) { + displaced_lms.y *= 2.f; + } else { + displaced_lms.z *= 2.f; + } + return psycho25_AdaptiveMBDirection( + displaced_lms, + current_adaptive_state_lms, + adapted_neutral_mb); +} + +Psycho25HueSection psycho25_HuePinIntervalForAngle( + float source_hue_angle, + float2 axis_l, + float2 axis_m, + float2 axis_s) { + // The raw per-cone field is exactly zero on each isolated-cone axis and its + // antipode. These rays delimit inversion intervals so every cone-axis pin is + // retained without a separate dominance-order topology. + float pin_l = psycho25_PositiveHueAngle(atan2(axis_l.y, axis_l.x)); + float pin_m = psycho25_PositiveHueAngle(atan2(axis_m.y, axis_m.x)); + float pin_s = psycho25_PositiveHueAngle(atan2(axis_s.y, axis_s.x)); + float pin_minus_l = psycho25_PositiveHueAngle(pin_l + PSYCHO25_PI); + float pin_minus_m = psycho25_PositiveHueAngle(pin_m + PSYCHO25_PI); + float pin_minus_s = psycho25_PositiveHueAngle(pin_s + PSYCHO25_PI); + + float angle = psycho25_PositiveHueAngle(source_hue_angle); + Psycho25HueSection interval; + if (angle >= pin_l || angle < pin_minus_m) { + interval.start = pin_l; + interval.end = pin_minus_m + PSYCHO25_TWO_PI; + interval.source_unwrapped = angle < pin_minus_m + ? angle + PSYCHO25_TWO_PI + : angle; + interval.index = 0u; + } else if (angle < pin_s) { + interval.start = pin_minus_m; + interval.end = pin_s; + interval.source_unwrapped = angle; + interval.index = 1u; + } else if (angle < pin_minus_l) { + interval.start = pin_s; + interval.end = pin_minus_l; + interval.source_unwrapped = angle; + interval.index = 2u; + } else if (angle < pin_m) { + interval.start = pin_minus_l; + interval.end = pin_m; + interval.source_unwrapped = angle; + interval.index = 3u; + } else if (angle < pin_minus_s) { + interval.start = pin_m; + interval.end = pin_minus_s; + interval.source_unwrapped = angle; + interval.index = 4u; + } else { + interval.start = pin_minus_s; + interval.end = pin_l; + interval.source_unwrapped = angle; + interval.index = 5u; + } + interval.midpoint = 0.5f * (interval.start + interval.end); + return interval; +} + +Psycho25HueEvaluationContext psycho25_PrepareHueEvaluationContext( + Psycho25ConeResponseParameters guidance_cone_response, + float3 current_adaptive_state_lms, + float3 anchor_in, + float3 anchor_out, + float3 guidance_lms_peak, + float2 adapted_neutral_mb, + float source_radius, + float source_target_yf, + float contrast_power, + int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { + Psycho25HueEvaluationContext context; + context.guidance_cone_response = guidance_cone_response; + context.current_adaptive_state_lms = current_adaptive_state_lms; + context.anchor_in = anchor_in; + context.anchor_out = anchor_out; + context.guidance_lms_peak = guidance_lms_peak; + context.adapted_neutral_mb = adapted_neutral_mb; + context.source_radius = source_radius; + context.source_target_yf = source_target_yf; + context.contrast_power = contrast_power; + context.observer_gamut_mode = observer_gamut_mode; + return context; +} + +float3x3 psycho25_WeightedLMSToRGBMatrix( + int gamut_mode) { + return gamut_mode == 0 + ? renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT709_MAT + : renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT2020_MAT; +} + +float3 psycho25_TargetRGBFromLMS( + float3 lms, + int gamut_mode) { + return mul( + psycho25_WeightedLMSToRGBMatrix(gamut_mode), + renodx::color::macleod_boynton::WeighLMS(lms)); +} + + float3 psycho25_LMSFromTargetRGB( + float3 target_rgb, + int gamut_mode) { + return gamut_mode == 0 + ? renodx::color::lms::from::BT709(target_rgb) + : renodx::color::lms::from::BT2020(target_rgb); + } + +float psycho25_TargetLowerPlaneBoundaryFraction( + float3 candidate_target_rgb, + float3 neutral_target_rgb) { + float boundary_fraction = PSYCHO25_LARGE; + if (candidate_target_rgb.x < neutral_target_rgb.x) { + boundary_fraction = min( + boundary_fraction, + neutral_target_rgb.x + / (neutral_target_rgb.x - candidate_target_rgb.x)); + } + if (candidate_target_rgb.y < neutral_target_rgb.y) { + boundary_fraction = min( + boundary_fraction, + neutral_target_rgb.y + / (neutral_target_rgb.y - candidate_target_rgb.y)); + } + if (candidate_target_rgb.z < neutral_target_rgb.z) { + boundary_fraction = min( + boundary_fraction, + neutral_target_rgb.z + / (neutral_target_rgb.z - candidate_target_rgb.z)); + } + return boundary_fraction; +} + + float3 psycho25_PullBackAdaptiveMBToTargetLowerPlanes( + float3 candidate_mb, + float2 adapted_neutral_mb, + float3 current_adaptive_state_lms, + int target_gamut_mode) { + float3 neutral_lms = psycho25_LMSFromAdaptiveMB( + float3(adapted_neutral_mb, candidate_mb.z), + current_adaptive_state_lms); + float3 candidate_lms = psycho25_LMSFromAdaptiveMB( + candidate_mb, + current_adaptive_state_lms); + float boundary_fraction = psycho25_TargetLowerPlaneBoundaryFraction( + psycho25_TargetRGBFromLMS(candidate_lms, target_gamut_mode), + psycho25_TargetRGBFromLMS(neutral_lms, target_gamut_mode)); + candidate_mb.xy = lerp( + adapted_neutral_mb, + candidate_mb.xy, + saturate(boundary_fraction)); + return candidate_mb; + } + +float psycho25_CompressTargetLowerPlaneRadius( + float boundary_fraction) { + float knee = PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE + * boundary_fraction; + float headroom = boundary_fraction - knee; + float excess = max(1.f - knee, 0.f); + return 1.f - excess + + renodx::math::DivideSafe( + headroom * excess, + headroom + excess, + 0.f); +} + +float psycho25_SmoothPositive(float value) { + float smooth_length = sqrt( + value * value + + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON + * PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); + float normalized_value = value / smooth_length; + return 0.5f + * value + * normalized_value + * (1.f + normalized_value); +} + +float psycho25_IntersectTargetPlaneSupports(float a, float b) { + float normalization = max(a, b); + float normalized_a = a / normalization; + float normalized_b = b / normalization; + float denominator = normalization * pow(pow(normalized_a, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER) + pow(normalized_b, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER), rcp(PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER)); + return a * b / denominator; +} + +float psycho25_IntersectTargetPlaneSupports(float3 support) { + return psycho25_IntersectTargetPlaneSupports( + support.x, + psycho25_IntersectTargetPlaneSupports( + support.y, + support.z)); +} + +float3 psycho25_LiftTargetRGBTowardWhite( + float3 candidate_lms, + float white_level, + int target_gamut_mode) { + float3 candidate_target_rgb = psycho25_TargetRGBFromLMS( + candidate_lms, + target_gamut_mode); + float3 white_target_rgb = white_level.xxx; + + // For q = lerp(candidate, white, t), each negative channel requires + // t >= -candidate / (white - candidate). SmoothPositiveMajorant is strictly + // no smaller than max(required, 0), and the bounded transform retains that + // conservative property. The union is smooth across active target planes + // and remains at least as large as every channel's required lift. + float3 required_lift = -candidate_target_rgb / max( + white_target_rgb - candidate_target_rgb, + PSYCHO25_EPSILON.xxx); + float3 smooth_positive_majorant = 0.5f * ( + required_lift + + sqrt( + required_lift * required_lift + + PSYCHO25_WHITE_LIFT_PRESSURE_EPSILON + * PSYCHO25_WHITE_LIFT_PRESSURE_EPSILON)); + float3 channel_lift = smooth_positive_majorant / ( + 1.f + smooth_positive_majorant - required_lift); + float minimum_white_lift = 1.f + - (1.f - channel_lift.x) + * (1.f - channel_lift.y) + * (1.f - channel_lift.z); + + // The minimum lift lands an outside point on its limiting lower plane. + // For fixed-ray occupancy s and t = 1 - 1/s, multiplying the remaining + // displacement by 1 - t^2 maps the result to occupancy 1 - t^2 inside that + // plane. It leaves the boundary with zero first-order inward motion, then + // converges to white as pressure grows instead of flattening the outside + // trajectory onto a target wall. The scalar residual keeps the selected- + // target D65-relative direction intact; the Reference2 wrapper below + // restores the exact Graph-authored adaptive-MB hue after the move. + float white_residual = (1.f - minimum_white_lift) + * (1.f - minimum_white_lift * minimum_white_lift); + float3 output_target_rgb = white_target_rgb + + white_residual * (candidate_target_rgb - white_target_rgb); + return psycho25_LMSFromTargetRGB( + output_target_rgb, + target_gamut_mode); +} + +float3 psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( + float3 candidate_lms, + float3 current_adaptive_state_lms, + float white_level, + int target_gamut_mode) { + float3 lifted_lms = psycho25_LiftTargetRGBTowardWhite( + candidate_lms, + white_level, + target_gamut_mode); + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 lifted_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + lifted_lms, + current_adaptive_state_lms)); + float2 candidate_direction = psycho25_AdaptiveMBDirection( + candidate_lms, + current_adaptive_state_lms, + adapted_neutral_mb); + float2 output_mb_xy = adapted_neutral_mb + + candidate_direction + * length(lifted_mb.xy - adapted_neutral_mb); + float output_yf = psycho25_YfFromLMS(lifted_lms); + float output_mb_scale = renodx::math::DivideSafe( + output_yf, + output_mb_xy.x * current_adaptive_state_lms.x + + (1.f - output_mb_xy.x) * current_adaptive_state_lms.y, + 0.f); + return psycho25_LMSFromAdaptiveMB( + float3(output_mb_xy, output_mb_scale), + current_adaptive_state_lms); +} + +float3 psycho25_CompressTargetHueTriangleVolume( + float3 preferred_lms, + float peak_value, + int target_gamut_mode) { + float safe_peak = max(peak_value, PSYCHO25_EPSILON); + float3 preferred_target_rgb = psycho25_TargetRGBFromLMS( + preferred_lms, + target_gamut_mode); + float minimum_channel = min( + preferred_target_rgb.x, + min(preferred_target_rgb.y, preferred_target_rgb.z)); + float maximum_channel = max( + preferred_target_rgb.x, + max(preferred_target_rgb.y, preferred_target_rgb.z)); + float channel_range = maximum_channel - minimum_channel; + + // For target RGB x, C = peak * (x - min(x)) / (max(x) - min(x)) + // is the exact hue-rim point with min(C)=0 and max(C)=peak. The raw + // barycentric weights reproduce every in-cube point exactly: + // x = black_weight * 0 + hue_weight * C + white_weight * peak.xxx. + float3 hue_rim_target_rgb = safe_peak + * (preferred_target_rgb - minimum_channel.xxx) + / max(channel_range, PSYCHO25_EPSILON); + float3 raw_weights = float3( + 1.f - maximum_channel / safe_peak, + channel_range / safe_peak, + minimum_channel / safe_peak); + + // Smoothly project invalid barycentric coordinates into the target + // triangle. This is effectively identity for positive in-volume weights + // and evaluates every simplex face together without authored section tests. + float3 positive_weights = float3( + psycho25_SmoothPositive(raw_weights.x), + psycho25_SmoothPositive(raw_weights.y), + psycho25_SmoothPositive(raw_weights.z)); + float3 contained_weights = positive_weights + / max( + positive_weights.x + positive_weights.y + positive_weights.z, + PSYCHO25_EPSILON); + + // Negative black weight means an upper-plane violation; negative white + // weight means a lower-plane violation. The squared pressure has zero slope + // at first contact, then approaches one under extreme pressure. Moving the + // contained point toward the triangle's white vertex makes white—not a dark + // target wall—the terminal fallback for either class of violation. + float upper_pressure = psycho25_SmoothPositive(-raw_weights.x); + float lower_pressure = psycho25_SmoothPositive(-raw_weights.z); + float upper_white_weight = upper_pressure * upper_pressure + / (1.f + upper_pressure * upper_pressure); + float lower_white_weight = lower_pressure * lower_pressure + / (1.f + lower_pressure * lower_pressure); + float pressure_white_weight = 1.f + - (1.f - upper_white_weight) * (1.f - lower_white_weight); + contained_weights = lerp( + contained_weights, + float3(0.f, 0.f, 1.f), + pressure_white_weight); + + float3 output_target_rgb = contained_weights.y * hue_rim_target_rgb + + contained_weights.z * safe_peak.xxx; + return psycho25_LMSFromTargetRGB( + output_target_rgb, + target_gamut_mode); +} + +float psycho25_TargetLowerPlaneRadiusForDirection( + float2 direction, + float2 adapted_neutral_mb, + float3 current_adaptive_state_lms, + int target_gamut_mode) { + float3 neutral_lms = psycho25_LMSFromAdaptiveMB( + float3(adapted_neutral_mb, 1.f), + current_adaptive_state_lms); + float3 unit_radius_lms = psycho25_LMSFromAdaptiveMB( + float3(adapted_neutral_mb + direction, 1.f), + current_adaptive_state_lms); + float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( + neutral_lms, + target_gamut_mode); + float3 direction_target_rgb = psycho25_TargetRGBFromLMS( + unit_radius_lms - neutral_lms, + target_gamut_mode); + float3 lower_support = neutral_target_rgb / (float3(psycho25_SmoothPositive(-direction_target_rgb.x), psycho25_SmoothPositive(-direction_target_rgb.y), psycho25_SmoothPositive(-direction_target_rgb.z)) + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); + return psycho25_IntersectTargetPlaneSupports(lower_support); +} + + float3 psycho25_CompressSectionalWhiteVolume( + float3 preferred_lms, + float3 current_adaptive_state_lms, + float peak_value, + int target_gamut_mode, + int gamut_enforcement) { + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + if (!enforce_gamut_primaries && !enforce_gamut_peak) { + return preferred_lms; + } + + // Hold the preferred physical result's Yf fixed and measure its selected- + // target RGB displacement from the D65 axis at that same Yf. Scaling this + // displacement therefore changes only the target-RGB color direction and + // magnitude, not the already-authored achromatic response. + float preferred_yf = psycho25_YfFromLMS(preferred_lms); + float3 neutral_lms = current_adaptive_state_lms + * renodx::math::DivideSafe( + preferred_yf, + psycho25_YfFromLMS(current_adaptive_state_lms), + 0.f); + float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( + neutral_lms, + target_gamut_mode); + float3 preferred_target_rgb = psycho25_TargetRGBFromLMS( + preferred_lms, + target_gamut_mode); + float3 target_displacement = preferred_target_rgb - neutral_target_rgb; + + // Each normalized occupancy is zero when its plane is not approached and + // one where the uncompressed displacement reaches that plane. Their L8 norm + // is a smooth conservative union of all enabled cube faces: it is never + // smaller than any individual occupancy, including at face/edge ties. + float3 lower_occupancy = 0.f.xxx; + float3 upper_occupancy = 0.f.xxx; + if (enforce_gamut_primaries) { + lower_occupancy = max(-target_displacement, 0.f.xxx) + / max(neutral_target_rgb, PSYCHO25_EPSILON.xxx); + } + if (enforce_gamut_peak) { + upper_occupancy = max(target_displacement, 0.f.xxx) + / max( + peak_value.xxx - neutral_target_rgb, + PSYCHO25_EPSILON.xxx); + } + float3 lower_occupancy_power = pow( + lower_occupancy, + PSYCHO25_SECTIONAL_VOLUME_POWER.xxx); + float3 upper_occupancy_power = pow( + upper_occupancy, + PSYCHO25_SECTIONAL_VOLUME_POWER.xxx); + float occupancy_power_sum = + lower_occupancy_power.x + + lower_occupancy_power.y + + lower_occupancy_power.z + + upper_occupancy_power.x + + upper_occupancy_power.y + + upper_occupancy_power.z; + + // One global saturation response replaces a black-to-wall/white handoff. + // It is nearly identity inside the cube, maps a single-face occupancy of one + // to pow(2, -1/8), and asymptotically approaches every active boundary from + // inside without a final component clamp. + float displacement_scale = pow( + 1.f + occupancy_power_sum, + -rcp(PSYCHO25_SECTIONAL_VOLUME_POWER)); + return psycho25_LMSFromTargetRGB( + neutral_target_rgb + target_displacement * displacement_scale, + target_gamut_mode); + } + +float3 psycho25_LMSFromHueDirectionAndYf( + float2 direction, + float source_radius, + float source_target_yf, + float3 current_adaptive_state_lms, + float2 adapted_neutral_mb) { + float3 candidate = psycho25_LMSFromAdaptiveMB( + float3(adapted_neutral_mb + direction * source_radius, 1.f), + current_adaptive_state_lms); + float candidate_yf = psycho25_YfFromLMS(candidate); + return candidate * renodx::math::DivideSafe(source_target_yf, candidate_yf, 1.f); +} + + +// Exact selected-target radial support at one adaptive-MB hue and physical Yf. +// Test25's adaptation-relative MB reconstruction makes target RGB a linear- +// fractional function of radius rather than a simple affine ray. Each enabled +// RGB cube face still has one closed-form scalar intersection, so no per-pixel +// search or LUT is required. +float psycho25_TargetRadialSupportAtYf( + float2 direction, + float target_yf, + float3 current_adaptive_state_lms, + float2 adapted_neutral_mb, + float peak_value, + int target_gamut_mode, + int gamut_enforcement) { + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + if (!enforce_gamut_primaries && !enforce_gamut_peak) { + return PSYCHO25_LARGE; + } + + // Along an adaptive-MB radial line + // l(r) = l0 + r*dl, s(r) = s0 + r*ds, + // the adaptation-relative weighted-LMS numerator is affine in r. Restoring + // absolute LMS and then fixing physical Yf divides by the affine L+M term, + // so each target RGB channel is a linear-fractional function: + // + // RGB_i(r) = target_yf * (N0_i + r*N1_i) / (D0 + r*D1). + // + // Intersecting RGB_i(r) with a lower plane 0 or upper plane peak therefore + // has one closed-form positive root. This is exact for Test25's adaptive-MB + // construction and avoids a per-pixel binary search. + float3 relative_weighted_zero = float3( + adapted_neutral_mb.x, + 1.f - adapted_neutral_mb.x, + adapted_neutral_mb.y); + float3 relative_weighted_delta = float3( + direction.x, + -direction.x, + direction.y); + float3 physical_weighted_zero = + relative_weighted_zero * current_adaptive_state_lms; + float3 physical_weighted_delta = + relative_weighted_delta * current_adaptive_state_lms; + float denominator_zero = + physical_weighted_zero.x + physical_weighted_zero.y; + float denominator_delta = + physical_weighted_delta.x + physical_weighted_delta.y; + + float3x3 weighted_lms_to_target_rgb = + psycho25_WeightedLMSToRGBMatrix(target_gamut_mode); + float3 numerator_zero = mul( + weighted_lms_to_target_rgb, + physical_weighted_zero); + float3 numerator_delta = mul( + weighted_lms_to_target_rgb, + physical_weighted_delta); + + float support = PSYCHO25_LARGE; + + if (enforce_gamut_primaries) { + // target_yf*(N0 + r*N1) = 0 + float3 lower_denominator = target_yf * numerator_delta; + float3 lower_numerator = -target_yf * numerator_zero; + + if (abs(lower_denominator.x) > PSYCHO25_EPSILON) { + float radius = lower_numerator.x / lower_denominator.x; + if (radius > 0.f + && denominator_zero + radius * denominator_delta + > PSYCHO25_EPSILON) { + support = min(support, radius); + } + } + if (abs(lower_denominator.y) > PSYCHO25_EPSILON) { + float radius = lower_numerator.y / lower_denominator.y; + if (radius > 0.f + && denominator_zero + radius * denominator_delta + > PSYCHO25_EPSILON) { + support = min(support, radius); + } + } + if (abs(lower_denominator.z) > PSYCHO25_EPSILON) { + float radius = lower_numerator.z / lower_denominator.z; + if (radius > 0.f + && denominator_zero + radius * denominator_delta + > PSYCHO25_EPSILON) { + support = min(support, radius); + } + } + } + + if (enforce_gamut_peak) { + // target_yf*(N0 + r*N1) = peak*(D0 + r*D1) + float3 upper_denominator = + target_yf * numerator_delta - peak_value * denominator_delta; + float3 upper_numerator = + peak_value * denominator_zero - target_yf * numerator_zero; + + if (abs(upper_denominator.x) > PSYCHO25_EPSILON) { + float radius = upper_numerator.x / upper_denominator.x; + if (radius > 0.f + && denominator_zero + radius * denominator_delta + > PSYCHO25_EPSILON) { + support = min(support, radius); + } + } + if (abs(upper_denominator.y) > PSYCHO25_EPSILON) { + float radius = upper_numerator.y / upper_denominator.y; + if (radius > 0.f + && denominator_zero + radius * denominator_delta + > PSYCHO25_EPSILON) { + support = min(support, radius); + } + } + if (abs(upper_denominator.z) > PSYCHO25_EPSILON) { + float radius = upper_numerator.z / upper_denominator.z; + if (radius > 0.f + && denominator_zero + radius * denominator_delta + > PSYCHO25_EPSILON) { + support = min(support, radius); + } + } + } + + return max(support, 0.f); +} + +// Bounded generalized-Neutwo pressure response used only after the canonical +// target occupancy exceeds one. `pivot` is measured in excess occupancy +// q - 1, `contrast` controls pressure gain, and `h` controls the shoulder. +float psycho25_CanonicalCylinderPressure( + float occupancy, + float pivot, + float contrast, + float h) { + float excess = max(occupancy - 1.f, 0.f); + if (excess <= PSYCHO25_EPSILON) return 0.f; + + float safe_pivot = max(pivot, PSYCHO25_EPSILON); + float safe_contrast = max(contrast, PSYCHO25_EPSILON); + float safe_h = max(h, PSYCHO25_EPSILON); + float normalized_excess = excess / safe_pivot; + + // Equivalent generalized-Neutwo forms chosen by magnitude avoid inf/inf + // when stress inputs produce extremely large target occupancy. + if (normalized_excess >= 1.f) { + float inverse_power = pow( + normalized_excess, + -safe_contrast * safe_h); + return pow(1.f + inverse_power, -rcp(safe_h)); + } + float z = pow(normalized_excess, safe_contrast); + return z / pow(1.f + pow(z, safe_h), rcp(safe_h)); +} + +// Canonical-cylinder device-volume experiment. +// +// 1) Convert the authored midpoint/Graph point to (theta, rho, Yf). +// 2) Normalize radius by the exact selected-target support: +// q = rho / rho_max(theta, Yf). +// 3) Keep every q <= 1 point exactly unchanged. +// 4) For q > 1, map excess pressure to w in [0,1), then move both inward in +// canonical q and upward toward peak D65 white. +// 5) Re-evaluate rho_max at the raised Yf and reconstruct the same adaptive-MB +// hue direction with rho_out = q_out * rho_max(theta, Yf_out). +// +// `trade` selects the balance: 0 = inward-first, 1 = upward/white-first. +float3 psycho25_CompressCanonicalCylinderVolume( + float3 preferred_lms, + float3 current_adaptive_state_lms, + float peak_value, + int target_gamut_mode, + int gamut_enforcement, + float pressure_pivot, + float pressure_contrast, + float pressure_h, + float pressure_trade) { + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + // This experiment is defined on the complete target RGB cube. Keep the + // independent lower-only / upper-only diagnostics on their existing paths + // rather than implicitly turning either one into full six-plane enforcement. + if (!enforce_gamut_primaries || !enforce_gamut_peak) { + return preferred_lms; + } + + float preferred_yf = psycho25_YfFromLMS(preferred_lms); + float target_peak_yf = psycho25_YfFromLMS( + psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode)); + if (!(preferred_yf > PSYCHO25_EPSILON) + || !(target_peak_yf > PSYCHO25_EPSILON)) { + return 0.f.xxx; + } + + // At or above the target's D65 peak cross-section the only full-cube point + // is peak white. This also avoids dividing by a vanishing radial support. + if (preferred_yf >= target_peak_yf * (1.f - PSYCHO25_EPSILON)) { + return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); + } + + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 preferred_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + preferred_lms, + current_adaptive_state_lms)); + float2 preferred_offset = preferred_mb.xy - adapted_neutral_mb; + float preferred_radius2 = dot(preferred_offset, preferred_offset); + if (preferred_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { + // A neutral point only needs peak containment, already handled above. + return preferred_lms; + } + + float preferred_radius = sqrt(preferred_radius2); + float2 direction = preferred_offset / preferred_radius; + float radial_support = psycho25_TargetRadialSupportAtYf( + direction, + preferred_yf, + current_adaptive_state_lms, + adapted_neutral_mb, + peak_value, + target_gamut_mode, + gamut_enforcement); + if (radial_support >= PSYCHO25_LARGE * 0.5f) { + return preferred_lms; + } + if (radial_support <= PSYCHO25_EPSILON) { + return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); + } + + float occupancy = preferred_radius / radial_support; + if (occupancy <= 1.f) { + return preferred_lms; + } + + float pressure = psycho25_CanonicalCylinderPressure( + occupancy, + pressure_pivot, + pressure_contrast, + pressure_h); + float residual = max(1.f - pressure, 0.f); + float trade = saturate(pressure_trade); + + // Exact viewer mapping: + // trade=0: q contracts rapidly while Yf rises slowly. + // trade=1: Yf rises rapidly while q contracts slowly. + float inward_power = exp2(2.f - 4.f * trade); + float upward_power = exp2(-2.f + 4.f * trade); + float output_occupancy = pow(residual, inward_power); + float preferred_y = saturate(preferred_yf / target_peak_yf); + float output_y = 1.f + - (1.f - preferred_y) * pow(residual, upward_power); + if (output_y >= 1.f - PSYCHO25_EPSILON) { + return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); + } + float output_yf = output_y * target_peak_yf; + + float output_support = psycho25_TargetRadialSupportAtYf( + direction, + output_yf, + current_adaptive_state_lms, + adapted_neutral_mb, + peak_value, + target_gamut_mode, + gamut_enforcement); + float output_radius = output_occupancy * max(output_support, 0.f); + return psycho25_LMSFromHueDirectionAndYf( + direction, + output_radius, + output_yf, + current_adaptive_state_lms, + adapted_neutral_mb); +} + + +// Canonical Yf-cone device-volume experiment. +// +// This variant keeps the same exact star-volume occupancy as Canonical +// Cylinder, but Yf controls *where gamut pressure is spent*: +// - all pressure participates in radial containment; +// - only pressure weighted by pow(Yf / peakYf, bias_power) can raise Yf. +// +// Consequently, dark saturated colors are pulled inward toward the target +// radial support without being spuriously lifted toward peak white. As Yf +// approaches target peak, the same out-of-volume pressure progressively turns +// into whiteward motion and every positive hue can still converge on peak D65. +float3 psycho25_CompressCanonicalYfConeVolume( + float3 preferred_lms, + float3 current_adaptive_state_lms, + float peak_value, + int target_gamut_mode, + int gamut_enforcement, + float pressure_pivot, + float pressure_contrast, + float pressure_h, + float yf_bias_power) { + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + if (!enforce_gamut_primaries || !enforce_gamut_peak) { + return preferred_lms; + } + + float preferred_yf = psycho25_YfFromLMS(preferred_lms); + float target_peak_yf = psycho25_YfFromLMS( + psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode)); + if (!(preferred_yf > PSYCHO25_EPSILON) + || !(target_peak_yf > PSYCHO25_EPSILON)) { + return 0.f.xxx; + } + if (preferred_yf >= target_peak_yf * (1.f - PSYCHO25_EPSILON)) { + return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); + } + + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 preferred_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + preferred_lms, + current_adaptive_state_lms)); + float2 preferred_offset = preferred_mb.xy - adapted_neutral_mb; + float preferred_radius2 = dot(preferred_offset, preferred_offset); + if (preferred_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { + return preferred_lms; + } + + float preferred_radius = sqrt(preferred_radius2); + float2 direction = preferred_offset / preferred_radius; + float radial_support = psycho25_TargetRadialSupportAtYf( + direction, + preferred_yf, + current_adaptive_state_lms, + adapted_neutral_mb, + peak_value, + target_gamut_mode, + gamut_enforcement); + if (radial_support >= PSYCHO25_LARGE * 0.5f) { + return preferred_lms; + } + if (radial_support <= PSYCHO25_EPSILON) { + return 0.f.xxx; + } + + float occupancy = preferred_radius / radial_support; + if (occupancy <= 1.f) { + return preferred_lms; + } + + float pressure = psycho25_CanonicalCylinderPressure( + occupancy, + pressure_pivot, + pressure_contrast, + pressure_h); + + float preferred_y = saturate(preferred_yf / target_peak_yf); + float safe_yf_bias_power = max(yf_bias_power, PSYCHO25_EPSILON); + float white_bias = pow(preferred_y, safe_yf_bias_power); + + // Full gamut pressure contracts canonical radius. Dark colors therefore + // spend essentially all of their correction budget radially. + float radial_residual = max(1.f - pressure, 0.f); + float output_occupancy = radial_residual; + + // Only the Yf-weighted part of pressure may move the point upward. This is + // the conical bias: whiteward motion vanishes toward black and increases + // continuously toward peak. + float white_pressure = pressure * white_bias; + float output_y = preferred_y + + (1.f - preferred_y) * white_pressure; + if (output_y >= 1.f - PSYCHO25_EPSILON) { + return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); + } + float output_yf = output_y * target_peak_yf; + + // The target cross-section changes after Yf motion, so convert the canonical + // occupancy back through the exact radial support at the new Yf. + float output_support = psycho25_TargetRadialSupportAtYf( + direction, + output_yf, + current_adaptive_state_lms, + adapted_neutral_mb, + peak_value, + target_gamut_mode, + gamut_enforcement); + float output_radius = output_occupancy * max(output_support, 0.f); + return psycho25_LMSFromHueDirectionAndYf( + direction, + output_radius, + output_yf, + current_adaptive_state_lms, + adapted_neutral_mb); +} + +Psycho25ConeResponseState psycho25_BuildConeResponseState( + float3 contrast_lms, + Psycho25ConeResponseParameters parameters) { + float3 contrast_ratio = contrast_lms / parameters.anchor_out; + + Psycho25ConeResponseState state; + state.compression_exponent = parameters.compression_exponent; + state.input_response_exponent = parameters.input_response_exponent; + state.encoded_peak_offset = parameters.encoded_peak_offset; + state.encoded_response = renodx::math::SignPow( + contrast_ratio, + parameters.compression_exponent + * parameters.encoded_response_power); + return state; +} + +Psycho25ConeResponseState psycho25_BuildConeResponseState( + float3 contrast_lms, + float3 anchor_out, + float3 lms_peak, + float contrast_power, + float compression_power, + float encoded_response_power) { + return psycho25_BuildConeResponseState( + contrast_lms, + psycho25_PrepareConeResponseParameters( + anchor_out, + lms_peak, + contrast_power, + compression_power, + encoded_response_power)); +} + +float3 psycho25_CompressionRolloffSignedPerCone( + float3 signed_contrast_lms, + Psycho25ConeResponseParameters parameters) { + Psycho25ConeResponseState response_state = + psycho25_BuildConeResponseState( + signed_contrast_lms, + parameters); + return renodx::math::SignPow( + response_state.encoded_response + / (abs(response_state.encoded_response) + + response_state.encoded_peak_offset), + parameters.inverse_compression_power); +} + +float3 psycho25_CompressionRolloffPerCone( + float3 contrast_lms, + float3 anchor_out, + float3 lms_peak, + float contrast_power, + float compression_power, + float encoded_response_power) { + return psycho25_CompressionRolloffSignedPerCone( + contrast_lms, + psycho25_PrepareConeResponseParameters( + anchor_out, + lms_peak, + contrast_power, + compression_power, + encoded_response_power)); +} + +float psycho25_CompressionRolloffScalar( + float input_value, + float anchor_out, + float peak_value, + float compression_power) { + if (input_value <= 0.f) return 0.f; + float anchor_over_peak = anchor_out / peak_value; + float anchor_peak_power = pow( + anchor_over_peak, + compression_power); + float compression_slope_norm = 1.f - anchor_peak_power; + float encoded_peak_offset = rcp(anchor_peak_power) - 1.f; + float input_response_power = compression_power + / compression_slope_norm; + float log_offset_over_input = log(max(encoded_peak_offset, 1e-30f)) + - input_response_power + * log(input_value / anchor_out); + float normalized_response = rcp( + 1.f + exp(clamp(log_offset_over_input, -80.f, 80.f))); + return peak_value * pow( + normalized_response, + rcp(compression_power)); +} + +float3 psycho25_ApplyPostTargetCompression( + float3 target_rgb, + float3 anchor_target_rgb, + float peak_value, + float compression_power, + int gamut_enforcement, + int post_compression_mode) { + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + if (enforce_gamut_primaries) { + target_rgb = max(target_rgb, 0.f.xxx); + } + if (!enforce_gamut_peak) return target_rgb; + + if (post_compression_mode == PSYCHO25_POST_COMPRESSION_PER_CHANNEL + || post_compression_mode + == PSYCHO25_POST_COMPRESSION_SOURCE_MB_PER_CHANNEL) { + float3 positive_rgb = max(target_rgb, 0.f.xxx); + float3 safe_anchor = clamp( + anchor_target_rgb, + PSYCHO25_EPSILON.xxx, + (peak_value - PSYCHO25_EPSILON).xxx); + float3 compressed_rgb = float3( + psycho25_CompressionRolloffScalar( + positive_rgb.x, + safe_anchor.x, + peak_value, + compression_power), + psycho25_CompressionRolloffScalar( + positive_rgb.y, + safe_anchor.y, + peak_value, + compression_power), + psycho25_CompressionRolloffScalar( + positive_rgb.z, + safe_anchor.z, + peak_value, + compression_power)); + return min(target_rgb, 0.f.xxx) + compressed_rgb; + } + + float max_target_channel = max( + abs(target_rgb.x), + max(abs(target_rgb.y), abs(target_rgb.z))); + if (max_target_channel <= PSYCHO25_EPSILON) return target_rgb; + float anchor_max_channel = max( + abs(anchor_target_rgb.x), + max(abs(anchor_target_rgb.y), abs(anchor_target_rgb.z))); + float compressed_max_channel = psycho25_CompressionRolloffScalar( + max_target_channel, + clamp( + anchor_max_channel, + PSYCHO25_EPSILON, + peak_value - PSYCHO25_EPSILON), + peak_value, + compression_power); + return target_rgb * (compressed_max_channel / max_target_channel); +} + +float3 psycho25_RestoreSourceAdaptiveMBDirection( + float3 candidate_lms, + float3 source_lms, + float3 current_adaptive_state_lms) { + float3 candidate_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + candidate_lms, + current_adaptive_state_lms)); + float3 source_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + source_lms, + current_adaptive_state_lms)); + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float2 candidate_offset = candidate_mb.xy - adapted_neutral_mb; + float2 source_offset = source_mb.xy - adapted_neutral_mb; + float candidate_radius2 = dot(candidate_offset, candidate_offset); + float source_radius2 = dot(source_offset, source_offset); + if (candidate_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON + || source_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { + return candidate_lms; + } + + candidate_mb.xy = adapted_neutral_mb + + source_offset * rsqrt(source_radius2) * sqrt(candidate_radius2); + return psycho25_LMSFromAdaptiveMB( + candidate_mb, + current_adaptive_state_lms); +} + + float3 psycho25_RestoreSourceBT709ResidualDirection( + float3 candidate_lms, + float3 source_lms, + float peak_value, + int target_gamut_mode, + int gamut_enforcement) { + float3 candidate_bt709 = renodx::color::bt709::from::LMS(candidate_lms); + float3 source_bt709 = renodx::color::bt709::from::LMS(source_lms); + float candidate_y = renodx::color::y::from::BT709(candidate_bt709); + float source_y = renodx::color::y::from::BT709(source_bt709); + float3 candidate_residual = candidate_bt709 - candidate_y.xxx; + float3 source_residual = source_bt709 - source_y.xxx; + float candidate_residual2 = dot(candidate_residual, candidate_residual); + float source_residual2 = dot(source_residual, source_residual); + if (candidate_residual2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON + || source_residual2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { + return candidate_lms; + } + + float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( + renodx::color::lms::from::BT709(candidate_y.xxx), + target_gamut_mode); + float3 candidate_target_rgb = psycho25_TargetRGBFromLMS( + renodx::color::lms::from::BT709( + candidate_y.xxx + + source_residual + * sqrt(candidate_residual2 / source_residual2)), + target_gamut_mode); + float3 target_residual = candidate_target_rgb - neutral_target_rgb; + float residual_scale = 1.f; + if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0) { + float3 lower_support = neutral_target_rgb + / max(-target_residual, PSYCHO25_EPSILON.xxx); + residual_scale = min( + residual_scale, + min(lower_support.x, min(lower_support.y, lower_support.z))); + } + if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0) { + float3 upper_support = (peak_value.xxx - neutral_target_rgb) + / max(target_residual, PSYCHO25_EPSILON.xxx); + residual_scale = min( + residual_scale, + min(upper_support.x, min(upper_support.y, upper_support.z))); + } + return psycho25_LMSFromTargetRGB( + neutral_target_rgb + target_residual * saturate(residual_scale), + target_gamut_mode); + } + +float3 psycho25_GamutCompressLMSBoundAdaptive( + float3 lms_input, + float3 current_adaptive_state_lms, + int target_gamut_mode, + float strength) { + float3 lms_weighted_relative = + psycho25_ToAdaptiveRelativeWeightedLMS( + lms_input, + current_adaptive_state_lms); + float3 lms_weighted_relative_out = + renodx::color::gamut::GamutCompressWeightedLMSCoreRGBBoundFromAdaptiveWeightedInput( + lms_weighted_relative, + current_adaptive_state_lms, + target_gamut_mode == 0 + ? renodx::color::macleod_boynton::BT709_TO_LMS_WEIGHTED_MAT + : renodx::color::macleod_boynton::BT2020_TO_LMS_WEIGHTED_MAT, + strength); + return renodx::color::macleod_boynton::UnweighLMS( + psycho25_FromAdaptiveRelativeWeightedLMS( + lms_weighted_relative_out, + current_adaptive_state_lms)); +} + + +bool psycho25_TargetRGBInsideEnabledHull( + float3 target_rgb, + float peak_value, + int gamut_enforcement) { + if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0 + && min(target_rgb.x, min(target_rgb.y, target_rgb.z)) < 0.f) { + return false; + } + if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0 + && max(target_rgb.x, max(target_rgb.y, target_rgb.z)) > peak_value) { + return false; + } + return true; +} + +// Bounded adaptation-relative ACHROMATIC contrast used only as a scalar +// bookkeeping metric after the ideal PsychoV result has been completed. +// +// The previous RMS-per-cone metric allowed chromatic loss to masquerade as a +// white/brightness deficit. Extremely saturated reds could therefore drift too +// far toward white simply because a legal fit removed adaptive-MB radius. +// +// Instead, the post-fit white budget is now driven only by Yf: +// +// A(Yf) = (Yf - Yf_adapt) / (abs(Yf) + abs(Yf_adapt)) +// +// This stays finite at black, is explicitly aligned with PsychoV's weighted +// LMS/Yf achromatic axis, and does not convert chromatic loss into white. +float psycho25_AchromaticYfContrast( + float3 lms, + float3 current_adaptive_state_lms) { + float signal_yf = psycho25_SignedYfFromLMS(lms); + float adapt_yf = psycho25_SignedYfFromLMS(current_adaptive_state_lms); + float safe_adapt_yf = max(abs(adapt_yf), PSYCHO25_EPSILON); + return (signal_yf - adapt_yf) + / (abs(signal_yf) + safe_adapt_yf); +} + +// Exact same-adaptive-MB-hue fit of a completed PsychoV point into the enabled +// selected-target RGB planes. +// +// The preferred physical Yf is retained whenever that Yf has a nonempty target +// cross-section. Only adaptive-MB radius is shortened, using the exact +// closed-form six-plane support already used by Test25: +// +// rho_out = min(rho_ideal, rho_max(theta, Yf)) +// +// No low-Y support approximation is used. In particular, rho_max does NOT +// collapse toward zero merely because Yf approaches black; black is reached by +// Yf -> 0 while chromaticity may remain saturated. If upper planes are enabled +// and Yf reaches the target D65 peak cross-section, peak white is the only +// legal full-cube point. +float3 psycho25_ExactAdaptiveMBTargetFit( + float3 ideal_lms, + float3 current_adaptive_state_lms, + float peak_value, + int target_gamut_mode, + int gamut_enforcement) { + if (gamut_enforcement == PSYCHO25_GAMUT_ENFORCEMENT_NONE) { + return ideal_lms; + } + + float3 ideal_target_rgb = psycho25_TargetRGBFromLMS( + ideal_lms, + target_gamut_mode); + if (psycho25_TargetRGBInsideEnabledHull( + ideal_target_rgb, + peak_value, + gamut_enforcement)) { + return ideal_lms; + } + + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + + float ideal_yf = psycho25_SignedYfFromLMS(ideal_lms); + if (!(ideal_yf > PSYCHO25_EPSILON)) { + if (enforce_gamut_primaries) { + return 0.f.xxx; + } + // Without lower-plane enforcement there is no positive-Yf ray constraint. + // Apply only the enabled upper planes as a numerical target-space fallback. + float3 target_rgb = ideal_target_rgb; + if (enforce_gamut_peak) { + target_rgb = min(target_rgb, peak_value.xxx); + } + return psycho25_LMSFromTargetRGB(target_rgb, target_gamut_mode); + } + + float3 target_peak_lms = psycho25_LMSFromTargetRGB( + peak_value.xxx, + target_gamut_mode); + float target_peak_yf = psycho25_SignedYfFromLMS(target_peak_lms); + if (enforce_gamut_peak + && ideal_yf >= target_peak_yf * (1.f - PSYCHO25_EPSILON)) { + return target_peak_lms; + } + + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 ideal_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + ideal_lms, + current_adaptive_state_lms)); + float2 ideal_offset = ideal_mb.xy - adapted_neutral_mb; + float ideal_radius2 = dot(ideal_offset, ideal_offset); + + // Neutral points have no radial degree of freedom. If one is still outside, + // only the enabled target planes can resolve it. + if (ideal_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { + float3 target_rgb = ideal_target_rgb; + if (enforce_gamut_primaries) { + target_rgb = max(target_rgb, 0.f.xxx); + } + if (enforce_gamut_peak) { + target_rgb = min(target_rgb, peak_value.xxx); + } + return psycho25_LMSFromTargetRGB(target_rgb, target_gamut_mode); + } + + float ideal_radius = sqrt(ideal_radius2); + float2 direction = ideal_offset / ideal_radius; + float radial_support = psycho25_TargetRadialSupportAtYf( + direction, + ideal_yf, + current_adaptive_state_lms, + adapted_neutral_mb, + peak_value, + target_gamut_mode, + gamut_enforcement); + + if (radial_support >= PSYCHO25_LARGE * 0.5f + || ideal_radius <= radial_support) { + return ideal_lms; + } + + float3 legal_lms = psycho25_LMSFromHueDirectionAndYf( + direction, + max(radial_support, 0.f), + ideal_yf, + current_adaptive_state_lms, + adapted_neutral_mb); + + // Exact support should already be legal. This final clamp covers only + // floating-point residue at a target plane. + float3 legal_target_rgb = psycho25_TargetRGBFromLMS( + legal_lms, + target_gamut_mode); + if (enforce_gamut_primaries) { + legal_target_rgb = max(legal_target_rgb, 0.f.xxx); + } + if (enforce_gamut_peak) { + legal_target_rgb = min(legal_target_rgb, peak_value.xxx); + } + return psycho25_LMSFromTargetRGB( + legal_target_rgb, + target_gamut_mode); +} + +// Post-ideal lost-contrast fit. +// +// 1) Complete Test25's ordinary physical/MIDPOINT result. +// 2) Fit that result to the exact selected-target six-plane support while +// retaining its adaptive-MB hue and Yf whenever the cross-section exists. +// 3) Measure only the bounded ACHROMATIC contrast lost by that legal fit: +// +// dA = max(A_ideal - A_legal, 0), +// +// where A is the Yf-based adaptation-relative contrast above. +// 4) Convert dA to a bounded Neutwo-like pressure. +// 5) Permit that pressure to become whiteward motion only in proportion to the +// square of physical Yf / target-peak Yf. +// +// Thus gamut fitting itself does not repower LMS ratios. Lost chromatic radius +// does not become white. Near black, the whiteward term vanishes quadratically +// and the exact same-hue legal result is retained. At high Yf, lost achromatic +// contrast may be spent along the legal point -> peak-D65-white segment, which +// remains inside the convex target RGB cube. +float3 psycho25_ApplyAdaptiveContrastFitLinearWhiteLegacy( + float3 ideal_lms, + float3 current_adaptive_state_lms, + float peak_value, + int target_gamut_mode, + int gamut_enforcement) { + if (gamut_enforcement == PSYCHO25_GAMUT_ENFORCEMENT_NONE) { + return ideal_lms; + } + + float3 legal_lms = psycho25_ExactAdaptiveMBTargetFit( + ideal_lms, + current_adaptive_state_lms, + peak_value, + target_gamut_mode, + gamut_enforcement); + + float ideal_contrast = psycho25_AchromaticYfContrast( + ideal_lms, + current_adaptive_state_lms); + float legal_contrast = psycho25_AchromaticYfContrast( + legal_lms, + current_adaptive_state_lms); + float lost_contrast = max(ideal_contrast - legal_contrast, 0.f); + if (lost_contrast <= PSYCHO25_EPSILON) { + return legal_lms; + } + + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + if (!enforce_gamut_peak) { + // Without an upper hull there is no defined target-white destination for + // the lost achromatic budget. Keep the exact same-hue fit. + return legal_lms; + } + + float3 target_peak_lms = psycho25_LMSFromTargetRGB( + peak_value.xxx, + target_gamut_mode); + float peak_contrast = psycho25_AchromaticYfContrast( + target_peak_lms, + current_adaptive_state_lms); + float available_contrast = max( + peak_contrast - legal_contrast, + PSYCHO25_EPSILON); + float normalized_loss = lost_contrast / available_contrast; + + // h=2 generalized-Neutwo occupancy: bounded [0,1), identity-like for small + // normalized loss and asymptotic under extreme out-of-hull stress. + float loss_pressure = normalized_loss + * rsqrt(1.f + normalized_loss * normalized_loss); + + float legal_yf = max(psycho25_SignedYfFromLMS(legal_lms), 0.f); + float target_peak_yf = max( + psycho25_SignedYfFromLMS(target_peak_lms), + PSYCHO25_EPSILON); + float yf_fraction = saturate(legal_yf / target_peak_yf); + float white_pressure = loss_pressure * yf_fraction * yf_fraction; + + float3 legal_target_rgb = psycho25_TargetRGBFromLMS( + legal_lms, + target_gamut_mode); + float3 output_target_rgb = lerp( + legal_target_rgb, + peak_value.xxx, + white_pressure); + + // Both endpoints are legal target-cube points, so this convex interpolation + // is legal by construction. Clamp only for floating-point residue. + if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0) { + output_target_rgb = max(output_target_rgb, 0.f.xxx); + } + output_target_rgb = min(output_target_rgb, peak_value.xxx); + return psycho25_LMSFromTargetRGB( + output_target_rgb, + target_gamut_mode); +} + +float3 psycho25_ApplyIndependentPostCompression( + float3 contrast_lms, + float3 source_lms, + float3 anchor_out, + float3 current_adaptive_state_lms, + float peak_value, + float compression_power, + int target_gamut_mode, + int gamut_enforcement, + int post_compression_mode) { + if (post_compression_mode == PSYCHO25_POST_COMPRESSION_DIRECT) { + return contrast_lms; + } + + float3 post_lms = contrast_lms; + if (post_compression_mode + == PSYCHO25_POST_COMPRESSION_SOURCE_MB_PER_CHANNEL + || post_compression_mode + == PSYCHO25_POST_COMPRESSION_SOURCE_MB_SOFT_MAX) { + post_lms = psycho25_RestoreSourceAdaptiveMBDirection( + post_lms, + source_lms, + current_adaptive_state_lms); + } + + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + if (enforce_gamut_primaries) { + if (post_compression_mode + == PSYCHO25_POST_COMPRESSION_ADAPTIVE_MB_HARD_MAX) { + float3 post_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + post_lms, + current_adaptive_state_lms)); + post_mb = psycho25_PullBackAdaptiveMBToTargetLowerPlanes( + post_mb, + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy, + current_adaptive_state_lms, + target_gamut_mode); + post_lms = psycho25_LMSFromAdaptiveMB( + post_mb, + current_adaptive_state_lms); + } else if (post_compression_mode + == PSYCHO25_POST_COMPRESSION_ADAPTIVE_MB_SOFT_MAX + || post_compression_mode + == PSYCHO25_POST_COMPRESSION_SOURCE_MB_SOFT_MAX) { + // Match PsychoV17's final device-map helper: adaptive-relative weighted + // LMS, the selected target-primary triangle, and strength 1. + post_lms = psycho25_GamutCompressLMSBoundAdaptive( + post_lms, + current_adaptive_state_lms, + target_gamut_mode, + 1.f); + } else if (post_compression_mode + == PSYCHO25_POST_COMPRESSION_FIXED_D65_SOFT_MAX) { + post_lms = target_gamut_mode == 0 + ? renodx::color::gamut::GamutCompressLMSBoundBT709(post_lms, 1.f) + : renodx::color::gamut::GamutCompressLMSBoundBT2020(post_lms, 1.f); + } + } + + float3 post_target_rgb = psycho25_TargetRGBFromLMS( + post_lms, + target_gamut_mode); + post_target_rgb = psycho25_ApplyPostTargetCompression( + post_target_rgb, + psycho25_TargetRGBFromLMS(anchor_out, target_gamut_mode), + peak_value, + compression_power, + gamut_enforcement, + post_compression_mode); + return psycho25_LMSFromTargetRGB( + post_target_rgb, + target_gamut_mode); +} + +float psycho25_EvaluateRawPerChannelHueShift( + float source_hue_angle, + Psycho25HueEvaluationContext context) { + float2 source_direction = + float2(cos(source_hue_angle), sin(source_hue_angle)); + float3 candidate_source_lms = + psycho25_LMSFromHueDirectionAndYf( + source_direction, + context.source_radius, + context.source_target_yf, + context.current_adaptive_state_lms, + context.adapted_neutral_mb); + + float3 contrast_lms = psycho25_ApplyContrastResponse( + candidate_source_lms, + context.anchor_in, + context.anchor_out, + context.contrast_power, + context.observer_gamut_mode); + float3 candidate_guidance_lms = context.guidance_lms_peak + * psycho25_CompressionRolloffSignedPerCone( + contrast_lms, + context.guidance_cone_response); + float2 compressed_direction = + psycho25_AdaptiveMBDirection( + candidate_guidance_lms, + context.current_adaptive_state_lms, + context.adapted_neutral_mb); + if (dot(compressed_direction, compressed_direction) + <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) return 0.f; + + return atan2( + psycho25_Cross2(source_direction, compressed_direction), + dot(source_direction, compressed_direction)); +} + +Psycho25HueGeometry psycho25_FindHueGeometry( + Psycho25HueSection section, + Psycho25HueEvaluationContext context) { + float step = (section.end - section.start) + / float(PSYCHO25_HUE_PEAK_SCAN_INTERVALS); + + float best_angle = section.midpoint; + float best_shift = psycho25_EvaluateRawPerChannelHueShift( + best_angle, + context); + float best_magnitude = abs(best_shift); + + [loop] + for (uint scan = 0u; + scan <= PSYCHO25_HUE_PEAK_SCAN_INTERVALS; + ++scan) { + if (scan == PSYCHO25_HUE_PEAK_SCAN_INTERVALS / 2u) continue; + float angle = section.start + step * float(scan); + float shift = psycho25_EvaluateRawPerChannelHueShift( + angle, + context); + if (abs(shift) > best_magnitude) { + best_angle = angle; + best_shift = shift; + best_magnitude = abs(shift); + } + } + + float lo = max(section.start, best_angle - step); + float hi = min(section.end, best_angle + step); + static const float golden = 0.6180339887498948482f; + float x1 = hi - golden * (hi - lo); + float x2 = lo + golden * (hi - lo); + float shift1 = psycho25_EvaluateRawPerChannelHueShift(x1, context); + float shift2 = psycho25_EvaluateRawPerChannelHueShift(x2, context); + float y1 = abs(shift1); + float y2 = abs(shift2); + + [loop] + for (uint iteration = 0u; + iteration < PSYCHO25_HUE_PEAK_REFINE_ITERATIONS; + ++iteration) { + if (y1 < y2) { + lo = x1; + x1 = x2; + y1 = y2; + shift1 = shift2; + x2 = lo + golden * (hi - lo); + shift2 = psycho25_EvaluateRawPerChannelHueShift(x2, context); + y2 = abs(shift2); + } else { + hi = x2; + x2 = x1; + y2 = y1; + shift2 = shift1; + x1 = hi - golden * (hi - lo); + shift1 = psycho25_EvaluateRawPerChannelHueShift(x1, context); + y1 = abs(shift1); + } + } + + float refined_angle = y1 >= y2 ? x1 : x2; + float refined_shift = y1 >= y2 ? shift1 : shift2; + if (abs(refined_shift) > best_magnitude) { + best_angle = refined_angle; + best_shift = refined_shift; + best_magnitude = abs(refined_shift); + } + + float peak_offset = best_angle - section.midpoint; + Psycho25HueGeometry geometry; + geometry.peak_angle = best_angle; + geometry.peak_shift = best_shift; + geometry.active = best_magnitude > PSYCHO25_EPSILON ? 1u : 0u; + geometry.axis_slope = geometry.active != 0u + ? (abs(peak_offset) > PSYCHO25_EPSILON + ? best_shift / peak_offset + : (best_shift < 0.f ? -PSYCHO25_LARGE : PSYCHO25_LARGE)) + : -2.2f; + geometry.maximum_ordered_amplitude = 1.f; + if (geometry.active != 0u + && abs(peak_offset) > PSYCHO25_EPSILON + && section.index == 2u) { + // The raw +S-to--L field can form a sharp Yf- and purity-dependent cusp. + // Its amplitude-1 endpoint remains available, but the authored field must + // not fold hue phase. Probe both cusp sides and cap only this pin interval's + // effective amplitude with margin. For the oblique inverse + // x = t - (1 - A) r(t) / s, y = x + A r(t), + // the ordered-phase coefficient is A - (1 - A) / s. + geometry.axis_slope = min( + geometry.axis_slope, + PSYCHO25_HUE_REVERSAL_AXIS_SLOPE_LIMIT); + float derivative_step = max( + step / PSYCHO25_HUE_ORDER_DERIVATIVE_PROBE_DIVISOR, + PSYCHO25_EPSILON); + float left_angle = max(section.start, best_angle - derivative_step); + float right_angle = min(section.end, best_angle + derivative_step); + float left_shift = psycho25_EvaluateRawPerChannelHueShift( + left_angle, + context); + float right_shift = psycho25_EvaluateRawPerChannelHueShift( + right_angle, + context); + float left_derivative = renodx::math::DivideSafe( + best_shift - left_shift, + best_angle - left_angle, + 0.f); + float right_derivative = renodx::math::DivideSafe( + right_shift - best_shift, + right_angle - best_angle, + 0.f); + float minimum_raw_derivative = min(left_derivative, right_derivative); + if (minimum_raw_derivative < -PSYCHO25_EPSILON) { + float maximum_raw_coefficient = PSYCHO25_HUE_ORDER_SAFETY + / -minimum_raw_derivative; + float inverse_axis_slope = 1.f / geometry.axis_slope; + geometry.maximum_ordered_amplitude = saturate( + (maximum_raw_coefficient + inverse_axis_slope) + / (1.f + inverse_axis_slope)); + } + } + return geometry; +} + +float psycho25_ForwardMappedHue( + float curve_parameter, + float amplitude, + float axis_slope, + Psycho25HueEvaluationContext context) { + float shift = psycho25_EvaluateRawPerChannelHueShift( + curve_parameter, + context); + // The graph-space operation has the closed form + // x' = x - (1 - A) * y / slope, y' = A * y. + // Inversion needs only X; the final consumed shift is its direct Y form. + return curve_parameter - (1.f - amplitude) * shift / axis_slope; +} + +float psycho25_SolveSextantHueShift( + Psycho25HueSection section, + Psycho25HueGeometry geometry, + float amplitude, + Psycho25HueEvaluationContext context) { + if (amplitude <= PSYCHO25_EPSILON || geometry.active == 0u) return 0.f; + if (min( + section.source_unwrapped - section.start, + section.end - section.source_unwrapped) + <= PSYCHO25_EPSILON) return 0.f; + + if (amplitude >= 1.f - PSYCHO25_EPSILON) { + return psycho25_EvaluateRawPerChannelHueShift( + section.source_unwrapped, + context); + } + + // The oblique graph transform can make mapped X locally nonmonotonic even + // when the final hue phase remains ordered. A whole-interval bisection then + // changes between distant roots under tiny input perturbations. Bracket all + // sign-changing roots at a fixed resolution and invert the one nearest the + // requested source phase, which is the local branch connected to the + // amplitude-1 identity transform. + float lo = section.start; + float hi = section.end; + float lo_value = psycho25_ForwardMappedHue( + lo, + amplitude, + geometry.axis_slope, + context) + - section.source_unwrapped; + float best_distance = PSYCHO25_LARGE; + float previous_parameter = lo; + float previous_value = lo_value; + [loop] + for (uint scan = 1u; + scan <= PSYCHO25_HUE_INVERSE_BRACKET_INTERVALS; + ++scan) { + float parameter = lerp( + section.start, + section.end, + float(scan) / float(PSYCHO25_HUE_INVERSE_BRACKET_INTERVALS)); + float value = psycho25_ForwardMappedHue( + parameter, + amplitude, + geometry.axis_slope, + context) + - section.source_unwrapped; + if (previous_value * value <= 0.f) { + float estimate_fraction = saturate(renodx::math::DivideSafe( + -previous_value, + value - previous_value, + 0.5f)); + float estimated_parameter = lerp( + previous_parameter, + parameter, + estimate_fraction); + float distance = abs( + estimated_parameter - section.source_unwrapped); + if (distance < best_distance) { + lo = previous_parameter; + hi = parameter; + lo_value = previous_value; + best_distance = distance; + } + } + previous_parameter = parameter; + previous_value = value; + } + + [loop] + for (uint iteration = 0u; + iteration < PSYCHO25_HUE_INVERSE_ITERATIONS; + ++iteration) { + float midpoint = 0.5f * (lo + hi); + float midpoint_value = psycho25_ForwardMappedHue( + midpoint, + amplitude, + geometry.axis_slope, + context) + - section.source_unwrapped; + if ((lo_value < 0.f) == (midpoint_value < 0.f)) { + lo = midpoint; + lo_value = midpoint_value; + } else { + hi = midpoint; + } + } + + float curve_parameter = 0.5f * (lo + hi); + float raw_shift = psycho25_EvaluateRawPerChannelHueShift( + curve_parameter, + context); + return amplitude * raw_shift; +} + +Psycho25AdaptiveMBTrajectory psycho25_BuildAdaptiveMBTrajectory( + float3 physical_magnitude_lms, + float3 guidance_direction_lms, + float3 direction_source_lms, + float3 current_adaptive_state_lms, + float3 anchor_in, + float3 anchor_out, + float3 guidance_lms_peak, + float contrast_power, + Psycho25ConeResponseParameters guidance_cone_response, + int hue_method, + int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { + float3 magnitude_relative_weighted = + psycho25_ToAdaptiveRelativeWeightedLMS( + physical_magnitude_lms, + current_adaptive_state_lms); + float3 magnitude_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + magnitude_relative_weighted); + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + Psycho25AdaptiveMBTrajectory trajectory; + trajectory.authored_mb = magnitude_mb; + trajectory.hue_applied = 0u; + + float3 source_relative_weighted = + psycho25_ToAdaptiveRelativeWeightedLMS( + direction_source_lms, + current_adaptive_state_lms); + float3 source_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + source_relative_weighted); + float3 compressed_direction_relative_weighted = + psycho25_ToAdaptiveRelativeWeightedLMS( + guidance_direction_lms, + current_adaptive_state_lms); + float3 compressed_direction_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + compressed_direction_relative_weighted); + + float2 magnitude_offset = magnitude_mb.xy - adapted_neutral_mb; + float2 source_offset = source_mb.xy - adapted_neutral_mb; + float2 compressed_direction_offset = + compressed_direction_mb.xy - adapted_neutral_mb; + float magnitude_radius2 = dot(magnitude_offset, magnitude_offset); + float source_radius2 = dot(source_offset, source_offset); + float compressed_direction_radius2 = dot( + compressed_direction_offset, + compressed_direction_offset); + if (magnitude_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON + || source_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON + || compressed_direction_radius2 + <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { + return trajectory; + } + + float magnitude_radius = sqrt(magnitude_radius2); + float source_radius = sqrt(source_radius2); + float2 source_direction = source_offset / source_radius; + if (hue_method == PSYCHO25_HUE_METHOD_FAST_60) { + // The fixed approximately 60-degree hue-graph assumption reduces the 50% + // operation to the angular midpoint between the source and current raw + // per-channel-compressed directions. Normalizing their linear midpoint is + // exact for equal-weight unit directions and avoids all graph searches. + float2 compressed_direction = compressed_direction_offset + * rsqrt(compressed_direction_radius2); + float2 output_direction = lerp( + source_direction, + compressed_direction, + 1.f - PSYCHO25_HUE_AMPLITUDE); + float output_direction2 = dot(output_direction, output_direction); + if (output_direction2 + <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) return trajectory; + output_direction *= rsqrt(output_direction2); + trajectory.authored_mb = float3( + adapted_neutral_mb + output_direction * magnitude_radius, + magnitude_mb.z); + trajectory.hue_applied = 1u; + return trajectory; + } + + float source_hue_angle = atan2(source_direction.y, source_direction.x); + Psycho25HueEvaluationContext context = psycho25_PrepareHueEvaluationContext( + guidance_cone_response, + current_adaptive_state_lms, + anchor_in, + anchor_out, + guidance_lms_peak, + adapted_neutral_mb, + source_radius, + psycho25_YfFromLMS(direction_source_lms), + contrast_power, + observer_gamut_mode); + + float2 axis_l = psycho25_IsolatedConeDisplacementAxis( + current_adaptive_state_lms, + adapted_neutral_mb, + 0u); + float2 axis_m = psycho25_IsolatedConeDisplacementAxis( + current_adaptive_state_lms, + adapted_neutral_mb, + 1u); + float2 axis_s = psycho25_IsolatedConeDisplacementAxis( + current_adaptive_state_lms, + adapted_neutral_mb, + 2u); + Psycho25HueSection section = psycho25_HuePinIntervalForAngle( + source_hue_angle, + axis_l, + axis_m, + axis_s); + Psycho25HueGeometry geometry = psycho25_FindHueGeometry( + section, + context); + float hue_shift = psycho25_SolveSextantHueShift( + section, + geometry, + min( + PSYCHO25_HUE_AMPLITUDE, + geometry.maximum_ordered_amplitude), + context); + float output_hue_angle = source_hue_angle + hue_shift; + float2 output_direction = + float2(cos(output_hue_angle), sin(output_hue_angle)); + trajectory.authored_mb = float3( + adapted_neutral_mb + output_direction * magnitude_radius, + magnitude_mb.z); + trajectory.hue_applied = 1u; + return trajectory; +} + +// Both output branches use the same prepared cone-response state. The direct +// branch returns its saturation shoulder; the gamut-active branch retains only +// its graph-solved adaptive-MB trajectory before target-plane +// compression. +float3 psycho25_ApplyPhysicalPerConePath( + float3 desired_lms, + float3 direction_source_lms, + float3 current_adaptive_state_lms, + float3 anchor_in, + float3 anchor_out, + float3 physical_lms_peak, + float contrast_power, + Psycho25ConeResponseParameters physical_cone_response, + int hue_method, + int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { + float3 physical_compressed_lms = physical_lms_peak + * psycho25_CompressionRolloffSignedPerCone( + desired_lms, + physical_cone_response); + Psycho25AdaptiveMBTrajectory trajectory = + psycho25_BuildAdaptiveMBTrajectory( + physical_compressed_lms, + physical_compressed_lms, + direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + physical_lms_peak, + contrast_power, + physical_cone_response, + hue_method, + observer_gamut_mode); + if (trajectory.hue_applied == 0u) return physical_compressed_lms; + float3 authored_lms = psycho25_LMSFromAdaptiveMB( + trajectory.authored_mb, + current_adaptive_state_lms); + return authored_lms * renodx::math::DivideSafe( + psycho25_YfFromLMS(physical_compressed_lms), + psycho25_YfFromLMS(authored_lms), + 1.f); +} + + +// Post-ideal contrast fit that follows Test25's own physical/MIDPOINT path. +// +// The exact same-hue target fit first removes only the adaptive-MB radius that +// the selected RGB cube cannot represent at the completed physical Yf. The +// removed chromatic fraction is NOT treated as equal-energy white. Instead it +// contributes to a trajectory-advance pressure only on the high side of the +// adapted state: +// +// chroma_loss = (rho_ideal - rho_legal) / rho_ideal +// yf_gate = saturate((Yf_legal - Yf_adapt) / (Yf_peak - Yf_adapt)) +// +// Genuine lost achromatic Yf contrast contributes independently. Their smooth +// union is bounded with the h=2 Neutwo response, then converted to one later +// post-contrast magnitude. Test25's per-cone shoulder and Graph/Fast60 authoring +// are re-evaluated ONCE at that later state, after which the exact six-plane fit +// is applied again. Thus red follows the same authored red->white trajectory +// instead of a straight target-RGB lerp to white. At/below adaptation, chroma +// loss alone cannot create whiteward motion. +float3 psycho25_ApplyAdaptiveContrastFit( + float3 ideal_lms, + float3 desired_lms, + float3 direction_source_lms, + float3 current_adaptive_state_lms, + float3 anchor_in, + float3 anchor_out, + float3 target_lms_peak, + float contrast_power, + Psycho25ConeResponseParameters target_cone_response, + int hue_method, + float peak_value, + int target_gamut_mode, + int gamut_enforcement, + int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { + if (gamut_enforcement == PSYCHO25_GAMUT_ENFORCEMENT_NONE) { + return ideal_lms; + } + + float3 legal_lms = psycho25_ExactAdaptiveMBTargetFit( + ideal_lms, + current_adaptive_state_lms, + peak_value, + target_gamut_mode, + gamut_enforcement); + + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 ideal_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + ideal_lms, + current_adaptive_state_lms)); + float3 legal_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + legal_lms, + current_adaptive_state_lms)); + float ideal_radius = length(ideal_mb.xy - adapted_neutral_mb); + float legal_radius = length(legal_mb.xy - adapted_neutral_mb); + float chroma_loss_fraction = saturate( + renodx::math::DivideSafe( + max(ideal_radius - legal_radius, 0.f), + ideal_radius, + 0.f)); + + float3 target_peak_lms = psycho25_LMSFromTargetRGB( + peak_value.xxx, + target_gamut_mode); + float legal_yf = max(psycho25_SignedYfFromLMS(legal_lms), 0.f); + float adapt_yf = psycho25_SignedYfFromLMS(current_adaptive_state_lms); + float target_peak_yf = max( + psycho25_SignedYfFromLMS(target_peak_lms), + adapt_yf + PSYCHO25_EPSILON); + float high_side_yf = saturate( + renodx::math::DivideSafe( + legal_yf - adapt_yf, + target_peak_yf - adapt_yf, + 0.f)); + float chroma_pressure = chroma_loss_fraction * high_side_yf; + + float ideal_achromatic = psycho25_AchromaticYfContrast( + ideal_lms, + current_adaptive_state_lms); + float legal_achromatic = psycho25_AchromaticYfContrast( + legal_lms, + current_adaptive_state_lms); + float peak_achromatic = psycho25_AchromaticYfContrast( + target_peak_lms, + current_adaptive_state_lms); + float lost_achromatic = max( + ideal_achromatic - legal_achromatic, + 0.f); + float achromatic_pressure = saturate( + renodx::math::DivideSafe( + lost_achromatic, + max(peak_achromatic - legal_achromatic, PSYCHO25_EPSILON), + 0.f)); + + // Smooth union of chromatic and achromatic pressure. Chromatic pressure is + // already Yf-weighted above, so saturated near-black colors do not advance. + float raw_pressure = 1.f + - (1.f - chroma_pressure) * (1.f - achromatic_pressure); + if (raw_pressure <= PSYCHO25_EPSILON) { + return legal_lms; + } + + float trajectory_pressure = raw_pressure + * rsqrt(1.f + raw_pressure * raw_pressure); + float trajectory_scale = rcp(max( + 1.f - trajectory_pressure, + PSYCHO25_EPSILON)); + + // desired_lms is already post-contrast. To keep the source state used by + // Graph/Fast60 consistent with that later contrast magnitude, invert the + // scalar contrast power for the pre-contrast direction source. + float safe_contrast_power = max( + contrast_power, + PSYCHO25_EPSILON); + float source_scale = pow( + trajectory_scale, + rcp(safe_contrast_power)); + + float3 advanced_ideal_lms = psycho25_ApplyPhysicalPerConePath( + desired_lms * trajectory_scale, + direction_source_lms * source_scale, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + contrast_power, + target_cone_response, + hue_method, + observer_gamut_mode); + + return psycho25_ExactAdaptiveMBTargetFit( + advanced_ideal_lms, + current_adaptive_state_lms, + peak_value, + target_gamut_mode, + gamut_enforcement); +} + +float3 psycho25_CompressTargetHull( + float3 desired_lms, + float3 direction_source_lms, + float3 current_adaptive_state_lms, + float3 anchor_in, + float3 anchor_out, + float3 target_lms_peak, + float3 guidance_lms_peak, + float contrast_power, + float upper_plane_shoulder_power, + Psycho25ConeResponseParameters target_cone_response, + Psycho25ConeResponseParameters guidance_cone_response, + float peak_value, + int target_gamut_mode, + int gamut_enforcement, // independent lower/upper target-plane bitmask + int hue_method, + int hull_method, + int upper_hull_pivot, + float canonical_pressure_pivot, + float canonical_pressure_contrast, + float canonical_pressure_h, + float canonical_pressure_trade, + float canonical_yf_bias_power, + int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { + const bool enforce_gamut_primaries = (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + float3 desired_weighted_lms = + renodx::color::macleod_boynton::WeighLMS(desired_lms); + float desired_yf = desired_weighted_lms.x + desired_weighted_lms.y; + + // Every nonblack color satisfying the selected target's lower RGB planes has + // positive Yf. A nonpositive-Yf direction therefore intersects those planes + // only at the origin. Without primary enforcement, this signed stress case + // has no stable positive-Yf hull ray, so retain the direct physical path + // instead of implicitly imposing lower planes. + if (desired_yf <= PSYCHO25_EPSILON) { + if (enforce_gamut_primaries) { + return 0.f.xxx; + } + return psycho25_ApplyPhysicalPerConePath( + desired_lms, + direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + contrast_power, + target_cone_response, + hue_method, + observer_gamut_mode); + } + + float anchor_out_yf = psycho25_YfFromLMS(anchor_out); + float target_peak_yf = psycho25_SignedYfFromLMS(target_lms_peak); + + // Keep magnitude/radius tied to the real target peak, but derive the + // compressed hue direction from the target-relative neutral guidance + // endpoint. At the 1x default this is exactly the physical endpoint and + // per-channel response. Carried scale is discarded before upper-plane + // support. + float3 physical_compressed_lms = target_lms_peak + * psycho25_CompressionRolloffSignedPerCone( + desired_lms, + target_cone_response); + float3 guidance_direction_lms = + guidance_lms_peak + * psycho25_CompressionRolloffSignedPerCone( + desired_lms, + guidance_cone_response); + Psycho25AdaptiveMBTrajectory trajectory = + psycho25_BuildAdaptiveMBTrajectory( + physical_compressed_lms, + guidance_direction_lms, + direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + guidance_lms_peak, + contrast_power, + guidance_cone_response, + hue_method, + observer_gamut_mode); + float3 safe_adaptive_state_lms = max( + current_adaptive_state_lms, + PSYCHO25_EPSILON.xxx); + float authored_yf = psycho25_YfFromLMS(physical_compressed_lms); + if (authored_yf <= PSYCHO25_EPSILON) { + if (enforce_gamut_primaries) { + return 0.f.xxx; + } + return psycho25_ApplyPhysicalPerConePath( + desired_lms, + direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + contrast_power, + target_cone_response, + hue_method, + observer_gamut_mode); + } + + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float2 authored_offset = + trajectory.authored_mb.xy - adapted_neutral_mb; + float authored_radius2 = dot(authored_offset, authored_offset); + float authored_radius = sqrt(authored_radius2); + float2 authored_direction = authored_offset * rsqrt( + authored_radius2 + PSYCHO25_EPSILON * PSYCHO25_EPSILON); + float3 source_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + direction_source_lms, + current_adaptive_state_lms)); + + if ((hull_method == PSYCHO25_HULL_METHOD_REFERENCE_SCALE + || hull_method == PSYCHO25_HULL_METHOD_REDUCED_MAX_WHITE) + && enforce_gamut_primaries) { + // Independent cone shoulders eventually make every positive source + // approach LMS white. As that physical radius disappears, turn its + // direction continuously toward the pre-contrast source direction so + // saturated blue cannot rotate through an unrelated purple direction. + // Keep the physical radius itself unchanged so the result can continue + // through light blue to white. This is one smooth trajectory rather than + // a level- or hue-segmented correction. + float2 source_offset = source_mb.xy - adapted_neutral_mb; + float source_radius2 = dot(source_offset, source_offset); + float source_radius = sqrt(source_radius2); + float2 source_direction = source_offset * rsqrt( + source_radius2 + PSYCHO25_EPSILON * PSYCHO25_EPSILON); + float source_radius_support = + psycho25_TargetLowerPlaneRadiusForDirection( + source_direction, + adapted_neutral_mb, + current_adaptive_state_lms, + target_gamut_mode); + float source_direction_occupancy = + hull_method == PSYCHO25_HULL_METHOD_REFERENCE_SCALE + ? PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY + : PSYCHO25_REDUCED_MAX_WHITE_SOURCE_DIRECTION_OCCUPANCY; + float source_direction_support_radius = source_direction_occupancy + * source_radius_support + * renodx::math::DivideSafe( + source_radius, + sqrt( + source_radius2 + + source_radius_support * source_radius_support), + 0.f); + float radius_normalization = max( + max(authored_radius, source_direction_support_radius), + PSYCHO25_EPSILON); + float authored_weight = pow( + authored_radius / radius_normalization, + PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); + float source_direction_support_weight = pow( + source_direction_support_radius / radius_normalization, + PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); + float source_hue_support = + PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION * source_radius_support; + float source_hue_confidence = renodx::math::DivideSafe( + source_radius2, + source_radius2 + source_hue_support * source_hue_support, + 0.f); + float source_collapse_weight = renodx::math::DivideSafe( + source_direction_support_weight, + authored_weight + source_direction_support_weight, + 0.f); + float source_direction_weight = 1.f + - (1.f - source_hue_confidence) + * (1.f - source_collapse_weight); + float2 combined_direction = lerp( + authored_direction, + source_direction, + source_direction_weight); + combined_direction *= rsqrt( + dot(combined_direction, combined_direction) + + PSYCHO25_EPSILON * PSYCHO25_EPSILON); + authored_direction = combined_direction; + authored_offset = authored_direction * authored_radius; + trajectory.authored_mb.xy = adapted_neutral_mb + authored_offset; + } + + if (hull_method == PSYCHO25_HULL_METHOD_LINEAR_MB_PULLBACK + && enforce_gamut_primaries + && authored_radius > PSYCHO25_EPSILON) { + // Diagnostic path: retain the Graph/Fast60-authored adaptive-MB direction + // and actual-peak physical radius until the candidate crosses a selected- + // target lower plane, then pull that radius straight back to the first + // intersection. There is no reference radius, knee, neutral release, + // smooth support intersection, or source-direction recovery. + trajectory.authored_mb = psycho25_PullBackAdaptiveMBToTargetLowerPlanes( + trajectory.authored_mb, + adapted_neutral_mb, + current_adaptive_state_lms, + target_gamut_mode); + authored_offset = trajectory.authored_mb.xy - adapted_neutral_mb; + authored_radius = length(authored_offset); + } + + // Normalization removes the trajectory guide's carried scale. Only its + // adaptive-MB direction and radius survive into the legacy cube ray. The + // final direction is normalized only after source retention or linear + // pullback so the later physical-Yf scale cannot inherit a stale x + // coordinate. + float trajectory_yf_for_normalization = trajectory.authored_mb.z * ( + trajectory.authored_mb.x * safe_adaptive_state_lms.x + + (1.f - trajectory.authored_mb.x) * safe_adaptive_state_lms.y); + float3 unit_yf_lms = psycho25_LMSFromAdaptiveMB( + float3( + trajectory.authored_mb.xy, + renodx::math::DivideSafe( + trajectory.authored_mb.z, + trajectory_yf_for_normalization, + 0.f)), + current_adaptive_state_lms); + float3 neutral_lms = current_adaptive_state_lms + / psycho25_YfFromLMS(current_adaptive_state_lms); + + if (hull_method == PSYCHO25_HULL_METHOD_CANONICAL_CYLINDER) { + return psycho25_CompressCanonicalCylinderVolume( + unit_yf_lms * authored_yf, + current_adaptive_state_lms, + peak_value, + target_gamut_mode, + gamut_enforcement, + canonical_pressure_pivot, + canonical_pressure_contrast, + canonical_pressure_h, + canonical_pressure_trade); + } + + if (hull_method == PSYCHO25_HULL_METHOD_CANONICAL_YF_CONE) { + return psycho25_CompressCanonicalYfConeVolume( + unit_yf_lms * authored_yf, + current_adaptive_state_lms, + peak_value, + target_gamut_mode, + gamut_enforcement, + canonical_pressure_pivot, + canonical_pressure_contrast, + canonical_pressure_h, + canonical_yf_bias_power); + } + + if (hull_method == PSYCHO25_HULL_METHOD_SECTIONAL_WHITE_VOLUME) { + // The per-cone response supplies white convergence, the Graph/Fast60 + // trajectory supplies its curved 50% six-section hue direction, and this + // one cross-sectional map contracts only the same-Yf radial displacement. + // No fixed-source recovery, second tone curve, or wall-to-white post pass + // is applied afterward. + return psycho25_CompressSectionalWhiteVolume( + unit_yf_lms * authored_yf, + current_adaptive_state_lms, + peak_value, + target_gamut_mode, + gamut_enforcement); + } + + if (hull_method == PSYCHO25_HULL_METHOD_REFERENCE3 + && enforce_gamut_primaries + && enforce_gamut_peak) { + return psycho25_CompressTargetHueTriangleVolume( + unit_yf_lms * authored_yf, + peak_value, + target_gamut_mode); + } + + // The function returns a linear BT.709 representation even when the selected + // target hull is BT.2020. Negative BT.709 components are valid for colors + // outside BT.709 but inside BT.2020, so lower-plane feasibility must be + // evaluated in the selected target RGB space. Reference and Reduced Max- + // White solve against a same-authored hue reference no nearer the adaptive + // neutral than either the physical trajectory or its uncompressed post- + // contrast input. Reference2 leaves this same-Yf radial stage untouched; + // its lower-plane correction lifts the completed candidate toward D65 white. + if (enforce_gamut_primaries + && (hull_method == PSYCHO25_HULL_METHOD_REFERENCE_SCALE + || hull_method == PSYCHO25_HULL_METHOD_REDUCED_MAX_WHITE) + && authored_radius > PSYCHO25_EPSILON) { + float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( + neutral_lms, + target_gamut_mode); + + // Fixed-Yf LMS interpolation is not exactly adaptive-MB radial + // interpolation. Apply the smooth shoulder to the current ray as the final + // target-plane safeguard. + float3 current_target_rgb = psycho25_TargetRGBFromLMS( + unit_yf_lms, + target_gamut_mode); + float current_boundary_fraction = + psycho25_TargetLowerPlaneBoundaryFraction( + current_target_rgb, + neutral_target_rgb); + float current_radius_scale = + psycho25_CompressTargetLowerPlaneRadius( + current_boundary_fraction); + + authored_direction = authored_offset / authored_radius; + float containment_reference_radius = max( + authored_radius, + length(source_mb.xy - adapted_neutral_mb)); + float3 reference_lms = psycho25_LMSFromAdaptiveMB( + float3( + adapted_neutral_mb + + authored_direction * containment_reference_radius, + 1.f), + current_adaptive_state_lms); + reference_lms /= psycho25_YfFromLMS(reference_lms); + float3 reference_target_rgb = psycho25_TargetRGBFromLMS( + reference_lms, + target_gamut_mode); + + // Find the selected-target lower-plane boundary along the complete + // neutral-to-reference ray even while the reference remains in gamut. + // A boundary fraction above one means the current reference is inside. + float reference_boundary_fraction = + psycho25_TargetLowerPlaneBoundaryFraction( + reference_target_rgb, + neutral_target_rgb); + + // Compress a unit input ray with a rational shoulder whose value and + // first derivative both match identity at the knee. The output approaches + // the exact lower-plane boundary asymptotically rather than changing + // behavior when a target channel first crosses zero. + float reference_radius_scale = psycho25_CompressTargetLowerPlaneRadius( + reference_boundary_fraction); + + float trajectory_fraction = + authored_radius / containment_reference_radius; + float release_progress = saturate( + trajectory_fraction + / PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION); + float neutral_scale = min(1.f, 4.f * reference_radius_scale); + float release_weight = 1.f - release_progress; + float radius_scale = min( + lerp( + reference_radius_scale, + neutral_scale, + release_weight * release_weight), + current_radius_scale); + + unit_yf_lms = lerp(neutral_lms, unit_yf_lms, radius_scale); + } + + // With peak planes disabled, retain the no-gamut trajectory's authored Yf. + // Primary enforcement may still reduce adaptive-MB chrominance to fit the + // selected target's nonnegative RGB half-spaces. + if (!enforce_gamut_peak) { + float3 candidate_lms = unit_yf_lms * authored_yf; + if ((hull_method != PSYCHO25_HULL_METHOD_REFERENCE2 + && hull_method != PSYCHO25_HULL_METHOD_REFERENCE3) + || !enforce_gamut_primaries) { + return candidate_lms; + } + float3 candidate_target_rgb = psycho25_TargetRGBFromLMS( + candidate_lms, + target_gamut_mode); + float white_level = max( + peak_value, + max( + candidate_target_rgb.x, + max(candidate_target_rgb.y, candidate_target_rgb.z))); + return psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( + candidate_lms, + current_adaptive_state_lms, + white_level, + target_gamut_mode); + } + + float3 unit_target_rgb = psycho25_TargetRGBFromLMS( + unit_yf_lms, + target_gamut_mode); + + float max_target_channel = max( + unit_target_rgb.x, + max(unit_target_rgb.y, unit_target_rgb.z)); + float directional_yf_limit = peak_value / max_target_channel; + float shoulder_input_yf = enforce_gamut_primaries + ? desired_yf + : authored_yf; + + if (upper_hull_pivot == PSYCHO25_UPPER_HULL_PIVOT_ADAPTED_OUTPUT) { + // Experimental adapted-output pivot. Express the authored candidate as a + // displacement from the output/background anchor, measure how much of the + // available per-channel upper-plane headroom that displacement occupies, + // then apply the selected scalar shoulder power over the + // adapted-Yf-to-peak range. Scaling the complete LMS displacement keeps + // the anchor exact and avoids turning signed target RGB into a channel + // clamp. + float3 candidate_lms = unit_yf_lms * shoulder_input_yf; + float3 candidate_target_rgb = psycho25_TargetRGBFromLMS( + candidate_lms, + target_gamut_mode); + float3 anchor_target_rgb = psycho25_TargetRGBFromLMS( + anchor_out, + target_gamut_mode); + float3 target_headroom = peak_value.xxx - anchor_target_rgb; + float upper_occupancy = 0.f; + if (candidate_target_rgb.x > anchor_target_rgb.x) { + upper_occupancy = max( + upper_occupancy, + (candidate_target_rgb.x - anchor_target_rgb.x) + / target_headroom.x); + } + if (candidate_target_rgb.y > anchor_target_rgb.y) { + upper_occupancy = max( + upper_occupancy, + (candidate_target_rgb.y - anchor_target_rgb.y) + / target_headroom.y); + } + if (candidate_target_rgb.z > anchor_target_rgb.z) { + upper_occupancy = max( + upper_occupancy, + (candidate_target_rgb.z - anchor_target_rgb.z) + / target_headroom.z); + } + if (upper_occupancy <= PSYCHO25_EPSILON) { + return hull_method == PSYCHO25_HULL_METHOD_REFERENCE2 + && enforce_gamut_primaries + ? psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( + candidate_lms, + current_adaptive_state_lms, + peak_value, + target_gamut_mode) + : candidate_lms; + } + + float centered_input_yf = anchor_out_yf + + upper_occupancy + * (target_peak_yf - anchor_out_yf); + float centered_output_yf = psycho25_CompressionRolloffScalar( + centered_input_yf, + anchor_out_yf, + target_peak_yf, + upper_plane_shoulder_power); + float output_occupancy = (centered_output_yf - anchor_out_yf) + / (target_peak_yf - anchor_out_yf); + float displacement_scale = output_occupancy / upper_occupancy; + float3 output_lms = anchor_out + + (candidate_lms - anchor_out) * displacement_scale; + return hull_method == PSYCHO25_HULL_METHOD_REFERENCE2 + && enforce_gamut_primaries + ? psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( + output_lms, + current_adaptive_state_lms, + peak_value, + target_gamut_mode) + : output_lms; + } + + float normalized_input = shoulder_input_yf + * renodx::math::DivideSafe( + target_peak_yf, + directional_yf_limit, + 1.f); + float normalized_output = psycho25_CompressionRolloffScalar( + normalized_input, + anchor_out_yf, + target_peak_yf, + upper_plane_shoulder_power); + float output_yf = normalized_output + * renodx::math::DivideSafe( + directional_yf_limit, + target_peak_yf, + 1.f); + float3 output_lms = unit_yf_lms * output_yf; + return hull_method == PSYCHO25_HULL_METHOD_REFERENCE2 + && enforce_gamut_primaries + ? psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( + output_lms, + current_adaptive_state_lms, + peak_value, + target_gamut_mode) + : output_lms; +} + +// psychov-25 research source record and device-hull plan +// ------------------------------------------------------ +// +// Objective: +// PsychoV first targets the observer-side bend of the scene: +// - what state the eye adapts to, +// - how the scene is converted to contrast around that adapted state, +// - how the response is shaped around that adapted state, +// - which nonlinear curve applies at each stage. +// The human observer is not a linear gain system, so the observer model decides +// which scene differences remain important when the display hull forces +// compression. Tonemapping itself remains a device-hull problem, not an eye +// model. +// +// The design therefore distinguishes two coupled systems: +// - observer flow: a literature-backed receptor/adaptation/opponent roadmap; +// - device-hull mapping: a joint tone, hue, and gamut solve over the complete +// display hull. +// +// Current Test25 implementation status: +// - implemented: relative scene-linear BT.709 -> Stockman/CVRL LMS, +// weighted-LMS/Yf/adaptive-MB bookkeeping, caller-provided adaptation +// anchors, scalar-Yf grading, adaptive-MB purity, anchor-matched contrast, +// a retained no-gamut per-cone rolloff, a numerical 50% hue-graph solve, +// actual-trajectory selected-target +// lower-plane containment, a physical no-gamut trajectory guide, +// independently selectable target lower-plane and upper-plane support, and +// one scalar peak shoulder over the resulting device-hull ray when requested; +// - planned or not implemented: absolute retinal calibration, adaptation-state +// estimation, calibrated cone-noise thresholds, absolute photopigment +// bleaching, +// ACC/DKL response, +// explicit ON/OFF splitting, pooled cortical gain, equivalent-Gaussian hue, +// and a wider sectional optimization over multiple in-sextant hull points. +// +// Rahimi-Nasrabadi et al. (Cell Reports 2021, +// doi:10.1016/j.celrep.2021.108692) validated their ONOFF image algorithm on +// calibrated grayscale images and suggested applying it to color through the +// lightness dimension. Test25 therefore keeps highlight/shadow grading on +// scalar Yf rather than independently grading L, M, and S. This citation does +// not make the current per-cone display rolloff a biological ON/OFF model. +// +// Research basis and intended human-flow model: +// +// 1) Receptor basis — implemented as a relative rendering basis. +// Stockman-Sharpe LMS with CIE 170-2 physiological luminance Yf / weighted +// LMS bookkeeping, not CIE 1931 Y. +// +// Reference split: +// - Brainard, "Colorimetry" (chapter 10): the cone stage / color-match +// foundation. Chapter 11 explicitly points back to this chapter when it +// says, "The first stage of color vision is now well understood (see +// Chap. 10)." This supports scene RGB/XYZ -> cone excitations L, M, S. +// - Stockman & Brainard (chapter 11): builds on that receptor basis for +// first-site and second-site adaptation. +// Sources: +// https://color2.psych.upenn.edu/brainard/papers/Brainard_Stockman_Colorimetry.pdf +// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf +// +// CVRL notes that cone signals are formed only after prereceptoral filtering +// by ocular media and macular pigment. Both absorb mainly at short +// wavelengths and vary substantially across observers. The transform is an +// average-observer receptor basis unless those filters are modeled +// explicitly. +// References: CVRL background hub; "Macular and lens pigments": +// http://www.cvrl.org/background.htm +// http://www.cvrl.org/database/text/intros/intromaclens.htm +// +// MacLeod-Boynton is not itself the cortical flow. It is a weighted +// cone-chromaticity representation in an equal-luminance plane with a +// separately carried achromatic scale term. In implementation notation: +// l = Lw / (Lw + Mw) +// s = Sw / (Lw + Mw) +// y = Lw + Mw +// The fixed observer-transform coefficients form weighted LMS, the Yf-like +// achromatic response, and MB coordinates from LMS. They are not adaptation, +// gain, or bleaching terms. CVRL describes the CIE physiological functions +// as linear transforms of the Stockman & Sharpe cone fundamentals. Mantiuk +// et al. describe practical LMS scaling so that L+M corresponds to +// luminance. This is the mathematical role of the weights at this stage. +// +// Reference: MacLeod & Boynton (1979), +// doi:10.1364/JOSA.69.001183; modern CIE 170-2 implementations replace ad +// hoc weights with standardized physiological cone-fundamental/luminance +// weights. +// +// Citation split for the coefficients used by the RenoDX transform: +// - explicit CIE 170-2 / physiological-weight usage: CIE/CVRL +// physiological functions, Psychtoolbox LMSToMacBoyn, and the repository +// Stockman/MacLeod-Boynton shader wiring; +// - classic or modified MB without an explicit CIE 170-2 coefficient claim: +// MacLeod & Boynton (1979), Webster & Leonard (2008); +// - LMS scaled so the achromatic term is L+M, without an explicit CIE 170-2 +// MB coefficient claim: Mantiuk et al. (2020). +// Classic MB, modified MB, and plain L+M-scaled LMS must not be cited as if +// they automatically justify the exact CIE 170-2 coefficients used here. +// Sources: +// http://www.cvrl.org/ciexyzpr.htm +// https://psychtoolbox.org/docs/LMSToMacBoyn +// https://pmc.ncbi.nlm.nih.gov/articles/PMC2657039/ +// https://www.cl.cam.ac.uk/~rkm38/pdfs/mantiuk2020practical_csf.pdf +// +// 2) Early cone adaptation — caller-provided anchors are implemented; +// adaptation estimation and a fitted physiological response are not. +// Maintain an adapting background state (L0, M0, S0, Yf0), then express the +// stimulus relative to that background before a postreceptoral transform. +// Chapter 10 gives absolute cone excitations; chapter 11 defines how they +// depend on the adapting background and become a contrast representation. +// +// Source-backed first-site math is cone-specific contrast/gain control, not +// a rule that every adapted background maps to one fixed output level. +// Stockman & Brainard write first-site L-cone contrast as: +// C_L = delta_L / (L_b + L_0) +// with analogous forms for M and S. Equivalently: +// g_L = 1 / (L_b + L_0) +// g_L * (L - L_b) = delta_L / (L_b + L_0) +// Thus the observer approximately normalizes cone signals by the adapted +// background. First-site adaptation is neither complete nor instantaneous; +// later second-site adaptation further reshapes postreceptoral signals. +// References: Stockman & Brainard (2010); Stockman et al. (JOV 2006, +// doi:10.1167/6.11.5). +// +// Webster & Leonard (2008) distinguish their "response norm," the adapting +// level that does not bias white judgments, from their "perceptual norm," the +// stimulus that appears white. Those norms tracked closely in their +// experiments, but neither is the same term as Stockman & Brainard's +// background cone excitations or Mantiuk et al.'s background responses. The +// directly modeled early state is best called the adapted background +// reference; response/perceptual norms are higher-level interpretations of +// why that reference acts as the current neutral coding state. +// Source: https://pmc.ncbi.nlm.nih.gov/articles/PMC2657039/ +// +// CVRL further notes that luminosity functions depend strongly on chromatic +// adaptation and observing conditions, whereas cone spectral sensitivities +// remain fixed until photopigment bleaching becomes significant. This is why +// Yf bookkeeping remains tied to the adapted observer state rather than a +// condition-invariant photometric curve. +// Reference: CVRL "Luminosity functions": +// http://www.cvrl.org/database/text/intros/introvl.htm +// +// 2a) Dim cone-noise regime — research plan, not implemented. +// Before rod-dominated vision, cone-mediated detection can already be +// limited by quantal/transduction noise. In this dim-but-still-cone regime, +// threshold cone contrast follows approximately De Vries-Rose behavior: in +// log-log space, threshold contrast falls with retinal illuminance at slope +// near -0.5. At higher levels the system approaches Weber-like behavior, +// where threshold contrast is roughly constant relative to the background. +// Weak scene differences may therefore disappear into a cone-noise-limited +// floor before rod vision dominates. +// Reference direction: +// - Stockman & Brainard (2010): cone-contrast space is most useful when +// first-site adaptation is in the Weber regime and less useful where +// adaptation falls short of Weber's law; +// - Angueyra & Rieke (2013): primate cone photoreceptors exhibit measurable +// phototransduction noise. +// Sources: +// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf +// https://pmc.ncbi.nlm.nih.gov/articles/PMC3815624/ +// 2b) High-light bleaching — research plan, not implemented. +// At sufficiently high retinal illuminance, a Rushton-Henry-style law in +// trolands describes per-cone pigment availability: +// p_available(I) = 1 / (1 + I / I0) +// This complements the commonly cited fraction-bleached law: +// p_bleached(I) = I / (I + I0) +// with I0 approximately 10^4.3 Td for cones. +// +// A rendering interpretation can apply availability to cone excursions +// around an adapted-white anchor so availability -> 0 approaches equal +// white at the carried achromatic level. That interpretation must not be +// confused with the current per-cone display rolloff. +// Sources and attribution: +// - Stockman et al. (JOV 2006, doi:10.1167/6.11.5): high-light sensitivity +// regulation is maintained mainly by photopigment bleaching; +// - Stockman et al. (JOV 2018, 18(6):12): appendix gives +// p = I / (I + I0), I0 = 10^4.3 Td, citing Rushton & Henry (1968); +// - CVRL "Bleaching": +// http://www.cvrl.org/database/text/intros/introbleaches.htm +// Physiological bleaching still belongs after the adapted background is +// defined and before postreceptoral opponent encoding, pooled gain, and +// device-hull mapping. +// +// 3) Background-normalized opponent drive — research plan beyond adaptive MB. +// Convert cone-domain responses into ACC/DKL-style opponent coordinates +// using a background-referenced weighted-LMS achromatic axis. MacLeod- +// Boynton describes chromaticity on an equal-luminance plane, whereas ACC / +// DKL are opponent combinations of cone increments around a background. MB +// therefore carries hue/device geometry and achromatic Yf bookkeeping here; +// ACC/DKL remains the planned space for postreceptoral response and gain. +// +// 4) Saturating contrast response — current rolloff is an engineering curve. +// A future receptor/early-cortical stage may use a Michaelis-Menten or +// Naka-Rushton-like nonlinearity. Some cortical fits may need a +// supersaturating variant. +// Reference: Peirce (JOV 2007, doi:10.1167/7.6.13). +// +// 5) ON/OFF separation — research constraint, not an explicit Test25 split. +// Split increments and decrements around the adapted/background state with +// half-wave rectification before pooled gain. The split is around +// adaptation, not diffuse white. Modern retina work also shows that ON/OFF +// nonlinearities can cancel in natural images, producing a more linear +// effective response than a single static saturating curve suggests. ON/OFF +// therefore constrains the neutral and OFF-side slope; it does not require a +// hard branch in the default curve. +// References: Schiller (1992); Yu, Turner, Baudin & Rieke, +// eLife 2022, 11:e70611, doi:10.7554/eLife.70611. +// +// 6) Pooled cortical gain — research plan, not implemented. +// A full observer stage still requires background-referenced opponency, +// ON/OFF separation, and fitted divisive gain parameters. +// References: Heeger (1992); Carandini & Heeger (2012); Bun & Horwitz +// (2023); Li et al. (2022). +// +// 7) Unified device-hull tonemapping and gamut mapping — active design plan. +// Map the observer-domain result into the display hull while retaining the +// most plausible achromatic and opponent contrast structure the device can +// represent. Diffuse/reference white, adapted neutral, and display peak are +// distinct anchors. ITU-R BT.2408's HDR Reference White framing is the +// practical video reference for keeping diffuse white below specular/display +// peak. +// +// Full normalized BT.709 hull: +// - peak 1.0 and BT.709 constraints together define 0 <= R,G,B <= 1; +// - this is one RGB cube, not a per-channel-to-white operation followed by a +// separate gamut constraint; +// - Test25 runtime units generalize the upper planes to `peak_value`, so the +// equivalent hull is 0 <= R,G,B <= peak_value in the selected target RGB +// basis; +// - the primary triangle is only the chromaticity-plane projection of part +// of this geometry. It does not describe upper faces or complete +// constant-scale cross-sections of the cube; +// - lower and upper channel faces, cube edges/corners, and relevant LMS +// bounds must be considered inside each cone-axis sextant; +// - BT.709 is the primary normalized design target. BT.2020 is a generalized +// target-mode extension, not a reason to weaken the BT.709 formulation. +// +// Sextant constraint: +// - isolated L/M/S displacement axes and their antipodes establish the six +// sections independently of any white rolloff or RGB target; +// - per-cone compression may supply one candidate interior hue objective, +// but it is not required to discover the sections and is not the hull; +// - the final solve must examine the complete target cross-section within +// the active sextant and LMS bounds, rather than assuming radial motion to +// adapted neutral is always optimal. +// +// Device-hull inference: +// - many display hulls can produce more total achromatic output by combining +// primaries than at the same level with a high-purity excursion; +// - an out-of-hull observer response may therefore trade chromatic shape +// toward the achromatic axis when the complete hull demands it; +// - the preferred result is not blind clipping to white, but the face, edge, +// corner, or interior point that best preserves observer-domain contrast +// structure; +// - white is one valid destination when bleaching or an achromatic optimum +// dominates, not the mandatory destination of gamut compression. +// +// Engineering direction inferred from the sources above: +// - use weighted LMS / MB to carry achromatic Yf and cone-axis geometry; +// - use an opponent representation to judge postreceptoral contrast; +// - construct and solve the full display hull in that combined state rather +// than first collapsing channels toward white and then clipping in RGB. +// +// Coupling constraint: +// - hue, tone, and device-hull compression are not independent steps; +// - a hue change after hull compression can push the result out of hull; +// - hue-preserving motion must be solved inside the hull projection or be +// followed by explicit in-hull reprojection; +// - the current complete-cube ray support proves containment with one scalar +// shoulder, but it is a partial implementation of the full sectional +// optimization rather than proof that its one authored direction is the +// globally preferred observer-domain trade. +// Reference direction: MacLeod-Boynton/CIE 170-2 geometry, repository +// weighted-LMS/MB transforms, and the device-hull notes above. +// +// 7a) Optional hue objective inside the device-hull solve — research plan. +// If display compression bends hue incorrectly, the solve may preserve an +// "equivalent Gaussian peak" proxy rather than a raw opponent angle. At +// short and medium wavelengths, perceived hue can behave more like a +// constant spectral peak of an equivalent Gaussian than a constant cone +// ratio as purity changes. +// Practical form: +// - offline, map weighted-LMS/MB chromaticities to an equivalent-Gaussian +// peak parameter mu_eq using a spectral forward model; +// - online, preserve mu_eq inside device-hull mapping while carrying Yf +// separately; +// - do not apply an unconstrained post-hoc hue shift after containment. +// This is an optional hull objective, not a chronological eye stage. +// References: Mizokami et al. (JOV 2006, doi:10.1167/6.9.12); +// O'Neil et al. (JOSAA 2012, doi:10.1364/JOSAA.29.00A165). +// +// 7b) Smooth auto-compression heuristic — currently implemented per cone. +// `compression == 0` derives h from the simultaneous-range reference above: +// one side around adaptation = reference_range_log10 / 2 +// h = (reference_range_log10 / 2) / log10(peak / anchor_out) +// pow(anchor_out / peak, h) = pow(10, -(reference_range_log10 / 2)) +// S_shadow = contrast / (1 - pow(anchor_out / peak, h)) +// The OFF/shadow slope error is derived from the selected reference range. +// Manual positive compression values remain exact. References: Kunkel & +// Reinhard, APGV 2010, doi:10.1145/1836248.1836251; Jiang & Fairchild, +// JIST 2021, doi:10.2352/J.ImagingSci.Technol.2021.65.5.050401. +// +// Current Test25 implementation map: +// ```mermaid +// flowchart LR +// rgb["Scene-linear BT.709"] --> lms["Stockman/CVRL LMS"] +// lms --> grade["Scalar-Yf highlights/shadows"] +// grade --> purity["Adaptive-MB purity"] +// purity --> contrast["Anchor-matched per-cone contrast"] +// contrast --> branch{"Gamut compression enabled?"} +// branch -->|No| rolloff["Retained per-cone LMS shoulder"] +// rolloff --> fallback["Numerical hue-graph solve"] +// branch -->|Yes| authored["Graph-solved trajectory direction"] +// authored --> direction["Continuous source-direction recovery"] +// direction --> planes["Physical radius + selected target planes"] +// planes --> scalar["One scalar shoulder over directional Yf support"] +// fallback --> output["BT.709-linear result"] +// scalar --> output +// ``` +// +// Research roadmap and source-state map: +// ```mermaid +// flowchart TB +// subgraph inputs["Raw inputs / assumptions"] +// rgb2["Scene-linear RGB"] +// colorimetry["Input RGB basis / white / RGB-to-LMS"] +// absolute["Absolute scene scale / retinal context"] +// background["Adaptation drivers / local background"] +// scene_range["Late image context / range"] +// observer["Stockman/CVRL observer assumptions"] +// display["Display primaries / white / peak / black / full hull"] +// end +// subgraph observer_flow["Observer roadmap"] +// receptor["Receptor LMS"] +// adapt["Adapted background reference"] +// cone_contrast["Per-cone background-relative response"] +// bleaching["Bleaching availability"] +// noise["Dim cone-noise visibility floor"] +// opponent["Opponent / achromatic response"] +// onoff["ON / OFF response"] +// gain["Pooled divisive normalization"] +// observer_out["Observer-domain response"] +// end +// subgraph device_map["Joint device-hull mapping"] +// hue_objective["Hue objective: MB / ACC / mu_eq"] +// sextants["Cone-axis sextants + LMS bounds"] +// cube["Full target RGB cube cross-sections"] +// hull_solve["Joint tone / hue / gamut solve"] +// hull_output["Display-hull output"] +// end +// rgb2 --> receptor +// colorimetry --> receptor +// observer --> receptor +// absolute --> receptor +// receptor --> adapt +// background --> adapt +// receptor --> cone_contrast +// adapt --> cone_contrast +// cone_contrast --> bleaching --> noise --> opponent --> onoff --> gain +// scene_range --> gain +// gain --> observer_out +// observer_out --> hue_objective +// observer_out --> hull_solve +// hue_objective --> hull_solve +// sextants --> hull_solve +// display --> cube --> hull_solve --> hull_output +// ``` +// +// Implementation scope: +// - The caller supplies the adapted source state and desired output background +// state. Neutral defaults are 0.18/0.18, so ordinary non-adapting content is +// not moved by the anchors. +// - The receptor basis is an average-observer, mainly foveal Stockman/CVRL +// basis with standard prereceptoral filtering folded into its functions. It +// is not a personalized observer model. +// - Scalar defaults are normalized rendering controls, not fitted +// physiological constants. +// - Conceptually, observer response and device mapping remain distinct. The +// current `psycho25_CompressTargetHull` combines authored hue, selected +// target-plane support, and scalar compression because they must remain +// coupled in practice. +// - Reference and Reduced Max-White derive a bounded direction-support scale +// from the pre-contrast source as independent cone shoulders approach LMS +// white. For source radius r_s and selected-target lower-plane support R_s: +// q_s = rho R_s r_s / sqrt(r_s^2 + R_s^2), +// with rho = 0.8 for Reference and 1 for Reduced Max-White. A quadratic +// collapse weight turns direction continuously toward the source as the +// physical authored radius vanishes. The output radius remains the physical +// radius, so chromatic highlights can still converge on white. The ordinary +// target solve supplies lower-plane correction and max-channel upper-plane +// support. No hue-sector branch, source gamut, output channel clamp, active +// limiting-face branch, retained radius, or segmented Yf range is introduced. + +// Public API contract: +// - `bt709_linear_input` is always scene/display-linear BT.709 RGB. Target +// gamut mode does not change this input conversion. +// - The return value is also represented as linear BT.709 RGB. A BT.2020 +// target may require negative BT.709 components; callers must convert to the +// target RGB space before applying target-space channel limits. +// - `peak_value` is the upper RGB-channel plane in units relative to the +// caller's reference white. A 100-nit peak / 100-nit reference-white test +// therefore uses 1. Runtime target containment is +// 0 <= target RGB <= peak_value. The caller must provide a positive peak +// whose D65 LMS and Yf values are strictly above the output/background +// anchor; invalid display configurations are not clamped or repaired. +// - `gamut_compression_mode`: 0 = BT.709 target, 1 = BT.2020 target. +// - Solved hue evaluation always carries the measured adaptive-MB radius. +// No source gamut is declared, inferred, or used as a normalization bound. +// - Hue authoring defaults to the numerical graph solve. `hue_method` selects +// the Fast60 comparison path, which uses the normalized 50% adaptive-MB +// midpoint and skips peak search plus inverse graph solving. +// - `hull_method` defaults to the reference-scale path, whose source-direction +// recovery uses 80% of its bounded target-relative support as the physical +// radius approaches white. Reduced Max-White raises that direction-support +// factor to 100%; neither mode retains a radius floor. Linear MB Pullback +// instead preserves the +// authored adaptive-MB direction and pulls its radius straight back to the +// first selected-target lower plane, with no lower-plane shoulder or custom +// radius construction. Target RGB Clip bypasses target-hull mapping and +// directly clamps the result in the selected linear BT.709 or BT.2020 RGB +// cube. +// - `hull_method == PSYCHO25_HULL_METHOD_CANONICAL_CYLINDER` selects the +// experimental canonical-cylinder map. It treats the authored adaptive-MB +// trajectory as the preferred point, computes exact selected-target radial +// support at its hue/Yf, forms q = rho/rho_max, leaves q <= 1 unchanged, and +// redirects q > 1 both inward and upward toward target peak D65 white. The +// four `canonical_pressure_*` controls match the interactive experiment: +// pivot = excess-occupancy scale, contrast = pressure exponent, h = bounded +// generalized-Neutwo shoulder, trade = 0 inward-first to 1 upward-first. +// The experiment is defined only for full lower+upper cube enforcement; +// partial plane modes remain on their existing diagnostic paths. +// - `post_compression_mode` selects an independent experiment. Modes Direct +// through Source MB Soft branch from the common post-contrast LMS signal and +// bypass physical per-cone output compression, Graph/Fast60 hue authoring, +// and coupled target-hull mapping. Direct applies no device constraint. +// Per-Channel and Max-Channel apply one +// selected-target RGB shoulder. Adaptive MB Hard, Adaptive MB Soft, and +// Fixed D65 Soft first apply their named lower-plane mapper and then the +// max-channel shoulder. Source MB variants first restore the pre-contrast +// adaptive-MB direction while retaining post-contrast radius and carried +// coordinate. Source BT709 Residual retains the normal coupled Reference +// result's relative luminance and linear-BT.709 residual magnitude, replaces +// only that residual direction with the source direction, and shortens it +// uniformly when selected-target containment requires it. The compatibility +// default is None. +// PsychoV17 Gamut + Neutwo Max retains the physical/hue direction but derives +// scalar magnitude from the common unbounded post-contrast signal after the +// same primary map. One anchor-normalized Neutwo shoulder is its peak map. +// - `PSYCHO25_POST_COMPRESSION_ADAPTIVE_CONTRAST_FIT` keeps Test25's completed +// physical/MIDPOINT result as the ideal point, fits it to the exact enabled +// selected-target six-plane support at the same adaptive-MB hue/Yf, then +// measures only bounded adaptation-relative Yf contrast lost by that fit. +// Lost chromatic radius is weighted by position above adapted Yf, genuine +// lost achromatic Yf is added independently, and their bounded pressure +// advances one later Test25 per-cone/MIDPOINT state before exact refitting. +// No straight RGB-to-white interpolation is used; near black chroma loss +// alone produces no trajectory advance. +// - `upper_hull_pivot` defaults to the existing black-origin constant-ratio +// peak ray. The experimental adapted-output mode instead applies the peak +// shoulder to target-channel headroom measured from `anchor_out`, keeping +// that adapted output/background state as the exact geometric pivot. +// - `compression`: positive = manual shoulder h; 0 = automatic h derived from +// the centered simultaneous-range reference. Manual h parameterizes both +// the no-gamut per-cone fallback and target-plane trajectory guide. Automatic +// h is resolved against each path's respective peak. Whenever any target +// plane is enabled, the direction guide uses a neutral endpoint of +// `target_peak_yf * guidance_peak_scale`; the real target peak remains +// unchanged for physical magnitude, radius, and upper-plane containment. +// - `guidance_peak_scale`: target-relative neutral Yf endpoint multiplier for +// target-plane hue guidance. It defaults to 1, is clamped to at least 1, +// and is ignored when no target planes are active. At 1x the guide is the +// regular physical per-channel shoulder. +// - `upper_plane_shoulder_power`: positive = independent upper-plane scalar +// shoulder h; 0 = match the resolved `compression` h. It has no effect when +// target peak/upper-plane enforcement is disabled. +// - `gamut_compression`: <= epsilon selects the retained per-cone LMS fallback; +// > epsilon selects both target-plane classes under legacy enforcement. +// Intermediate strength values are intentionally not a blend between two +// compressors. +// - `gamut_enforcement` independently selects target primary/lower-plane and +// target peak/upper-plane enforcement. With peak enforcement disabled, the +// gamut branch retains the authored Yf instead of imposing an RGB-channel +// peak. The legacy default follows `gamut_compression`: disabled maps to no +// target planes and enabled maps to both plane classes. +// - `cone_response_exponent` remains the response multiplier over the direct +// adapted-LMS contrast and purity controls. `encoded_response_power` is an +// adapted-anchor-preserving power in the compression-encoded response +// domain. +// - `input_pre_step` optionally retains signed LMS, clamps to positive LMS, +// clips to CIE 170-2, or aligns the signed MB hue ray to CIE 170-2 while +// retaining absolute Yf. +// - `observer_gamut_mode` is independent of `input_pre_step` and selected +// target gamut. CIE 170-2 mode constrains actual LMS immediately after +// per-cone contrast, before the physical/guidance shoulders and hue graph. +// It projects to the exact CIE 170-2 MacLeod-Boynton boundary along the +// fixed D65-relative hue ray while carrying nonnegative weighted L+M. The +// graph applies the same constraint to each candidate contrast response. +// None is the compatibility default. +// - `clip_point`, `hue_restore`, `white_curve_mode`, `adaptive_normalization`, +// `bleaching_intensity`, `highlight_saturation`, and `gamut_hue_restore` +// are retained for source compatibility but ignored. +float3 psychotm_test25( + float3 bt709_linear_input, // linear BT.709 RGB + float peak_value = 1000.f / 203.f, // target RGB upper plane + float exposure = 1.f, // linear scaling + float highlights = 1.f, // scalar-Yf highlight grade + float shadows = 1.f, // scalar-Yf shadow grade + float contrast = 1.f, // anchor-matched contrast + float purity_scale = 1.f, // adaptive-MB purity/contrast + float bleaching_intensity = 1.f, // ignored + float clip_point = 100.f, // ignored + float hue_restore = 1.f, // ignored + float encoded_response_power = 1.f, // encoded-domain power + int white_curve_mode = 0, // ignored + float cone_response_exponent = 1.f, // contrast/purity response + float3 current_adaptive_state_bt709 = 0.18f, // input/adaptation anchor + float3 current_background_state_bt709 = 0.18f, // output/background anchor + float gamut_compression = 1.f, // 0 per-cone; >0 legacy full hull + int gamut_compression_mode = 1, // target: BT.709/BT.2020 + float adaptive_normalization = 1.f, // ignored + float compression = 0.f, // shoulder h; 0 = auto + float highlight_saturation = 1.f, // ignored + float gamut_hue_restore = 0.f, // ignored + int hue_method = PSYCHO25_HUE_METHOD_GRAPH, + int hull_method = PSYCHO25_HULL_METHOD_REFERENCE_SCALE, + int gamut_enforcement = PSYCHO25_GAMUT_ENFORCEMENT_LEGACY, + int upper_hull_pivot = PSYCHO25_UPPER_HULL_PIVOT_BLACK, + float upper_plane_shoulder_power = PSYCHO25_UPPER_PLANE_SHOULDER_POWER_MATCH_COMPRESSION, + float guidance_peak_scale = PSYCHO25_DEFAULT_GUIDANCE_PEAK_SCALE, + int input_pre_step = PSYCHO25_INPUT_PRESTEP_NONE, + int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE, + int post_compression_mode = PSYCHO25_POST_COMPRESSION_NONE, + float canonical_pressure_pivot = PSYCHO25_CANONICAL_CYLINDER_DEFAULT_PIVOT, + float canonical_pressure_contrast = PSYCHO25_CANONICAL_CYLINDER_DEFAULT_CONTRAST, + float canonical_pressure_h = PSYCHO25_CANONICAL_CYLINDER_DEFAULT_H, + float canonical_pressure_trade = PSYCHO25_CANONICAL_CYLINDER_DEFAULT_TRADE, + float canonical_yf_bias_power = PSYCHO25_CANONICAL_YF_CONE_DEFAULT_BIAS_POWER) { + float response_scale = cone_response_exponent; + contrast *= response_scale; + purity_scale *= response_scale; + float safe_encoded_response_power = encoded_response_power; + + // The synthetic EXR stress chart contains binary16 infinities. Letting those + // enter the LMS matrices creates NaNs, which bypass gamut/peak comparisons + // and are later displayed at the presenter's safety clamp. Preserve their + // signs at the largest finite binary16 value; map undefined NaNs to black. + float3 exposed_bt709 = bt709_linear_input * exposure; + float3 finite_bt709_input = renodx::math::ZeroNaN(exposed_bt709); + finite_bt709_input = renodx::math::Select( + isinf(finite_bt709_input), + renodx::math::CopySign(65504.f.xxx, finite_bt709_input), + finite_bt709_input); + float3 lms_in = + renodx::color::lms::from::BT709(finite_bt709_input); + lms_in = psycho25_ApplyInputPreStep(lms_in, input_pre_step); + float3 target_lms_peak = + renodx::color::lms::from::BT709(float(peak_value).xxx); + float3 current_adaptive_state_lms = + renodx::color::lms::from::BT709(current_adaptive_state_bt709); + float3 desired_background_state_lms = + renodx::color::lms::from::BT709(current_background_state_bt709); + + // ------------------------------------------------------------------------- + // Anchor-matched adapted-D65 response. + // input == anchor_in maps to anchor_out for any compression setting. + // Test25 accepts these states from the caller; it does not estimate retinal + // adaptation or bleaching internally. + // ------------------------------------------------------------------------- + float3 anchor_in = current_adaptive_state_lms; + float3 anchor_out = desired_background_state_lms; + float contrast_power = contrast; + + // ------------------------------------------------------------------------- + // Achromatic highlight/shadow controls. + // The ONOFF source is luminance-only. Evaluating the grading curves once on + // Yf and applying a scalar gain to the complete LMS vector avoids an + // unsupported independent L/M/S grade and its resulting hue rotation. + // Cone signs are retained through authored hue and target containment. + // ------------------------------------------------------------------------- + float3 graded_lms = abs(lms_in); + float graded_yf = psycho25_YfFromLMS(graded_lms); + float adapted_anchor_yf = psycho25_YfFromLMS(anchor_in); + float graded_yf_out = psycho25_HighlightsScalarV4( + graded_yf, + highlights, + adapted_anchor_yf); + graded_yf_out = psycho25_ShadowsScalarV4( + graded_yf_out, + shadows, + adapted_anchor_yf); + graded_lms *= renodx::math::DivideSafe( + graded_yf_out, + graded_yf, + 1.f); + graded_lms = renodx::math::CopySign(graded_lms, lms_in); + + // ------------------------------------------------------------------------- + // Purity delta in adaptive MB: + // purity_delta = purity / contrast + // contrast == purity: no purity change. + // purity > contrast: increase radius from adapted neutral. + // purity < contrast: reduce radius toward adapted neutral. + // ------------------------------------------------------------------------- + float purity_delta = renodx::math::DivideSafe(purity_scale, contrast_power, 1.f); + float3 contrast_input = psycho25_ApplyAdaptiveMBPurity( + graded_lms, + anchor_in, + purity_delta); + + // ------------------------------------------------------------------------- + // Anchor-matched contrast remains explicit before display compression so + // source adaptive-MB direction/radius and the current rolloff-derived hue + // field can be evaluated separately. The optional observer-gamut stage is + // applied here, after contrast rather than as an input pre-step, and to the + // corresponding post-contrast state of every numerical hue-graph candidate. + // ------------------------------------------------------------------------- + float3 contrast_lms = psycho25_ApplyContrastResponse( + contrast_input, + anchor_in, + anchor_out, + contrast_power, + observer_gamut_mode); + + // ------------------------------------------------------------------------- + // Display-compression shoulder parameter. + // Positive `compression` is manual h; zero selects the centered-range auto + // value. The helpers implement the slope-normalized formula documented + // above. This resolved h parameterizes the no-gamut per-cone fallback and + // the real-peak magnitude in target-plane mode. An automatic direction guide + // resolves h again against its target-relative guidance endpoint; a positive + // manual h remains shared. The upper-plane scalar shoulder matches the real-peak h by + // default but can use its own positive h for diagnosis. + // Its slope-normalized power first encodes an adapted cone-response state. + // Sign-preserving encoded-response power is applied in that domain before + // the rational shoulder generates the channel scale. Hue authoring carries + // the measured adaptive-MB radius in both comparison modes. + // ------------------------------------------------------------------------- + float target_compression_power = compression; + if (compression == PSYCHO25_AUTO_COMPRESSION_SENTINEL) { + target_compression_power = + psycho25_AutoCompressionFromCenteredReferenceRange( + psycho25_YfFromLMS(anchor_out), + psycho25_YfFromLMS(target_lms_peak)); + } + target_compression_power = max( + target_compression_power, + PSYCHO25_MIN_MANUAL_COMPRESSION); + float resolved_upper_plane_shoulder_power = upper_plane_shoulder_power; + if (upper_plane_shoulder_power + == PSYCHO25_UPPER_PLANE_SHOULDER_POWER_MATCH_COMPRESSION) { + resolved_upper_plane_shoulder_power = target_compression_power; + } + resolved_upper_plane_shoulder_power = max( + resolved_upper_plane_shoulder_power, + PSYCHO25_MIN_MANUAL_COMPRESSION); + + // ------------------------------------------------------------------------- + // Coupled authored-hue and device-hull stage. With any target plane active, + // the per-cone guide uses a target-relative neutral Yf endpoint. At the 1x + // default this is the same endpoint as regular per-channel compression. The + // configured target peak remains the + // actual upper cube plane. Numerical mode solves the hue graph; Fast60 uses + // its direct angular midpoint with the source. Both retain the actual-peak + // physical radius and discard carried scale before target support is solved. + // Enabled target lower planes constrain adaptive-MB chrominance. Enabled + // upper planes define one directional Yf limit, and one scalar shoulder maps + // into it. This remains a single hull-ray solve, not the planned sectional + // optimization over multiple candidate points. + // ------------------------------------------------------------------------- + int normalized_target_gamut_mode = gamut_compression_mode == 0 ? 0 : 1; + const bool use_psychov17_gamut = post_compression_mode + == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT + || post_compression_mode + == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NEUTWO_MAX + || post_compression_mode + == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NRG_WHITE; + const bool use_psychov17_neutwo_peak = post_compression_mode + == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NEUTWO_MAX; + const bool use_psychov17_nrg_white = post_compression_mode + == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NRG_WHITE; + const bool use_adaptive_contrast_fit = post_compression_mode + == PSYCHO25_POST_COMPRESSION_ADAPTIVE_CONTRAST_FIT; + int resolved_gamut_enforcement = gamut_enforcement < 0 + ? (gamut_compression <= PSYCHO25_EPSILON + ? PSYCHO25_GAMUT_ENFORCEMENT_NONE + : PSYCHO25_GAMUT_ENFORCEMENT_FULL) + : gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_FULL; + Psycho25ConeResponseParameters target_cone_response = + psycho25_PrepareConeResponseParameters( + anchor_out, + target_lms_peak, + contrast_power, + target_compression_power, + safe_encoded_response_power); + float3 signed_direction_source_lms = contrast_input; + float3 output_lms; + if (post_compression_mode >= PSYCHO25_POST_COMPRESSION_DIRECT + && post_compression_mode <= PSYCHO25_POST_COMPRESSION_SOURCE_MB_SOFT_MAX + && resolved_gamut_enforcement != PSYCHO25_GAMUT_ENFORCEMENT_NONE) { + // Post experiments deliberately branch before every physical per-cone, + // Graph/Fast60, and coupled-hull output operation. The optional observer + // constraint remains an independent earlier stage through contrast_lms. + output_lms = psycho25_ApplyIndependentPostCompression( + contrast_lms, + signed_direction_source_lms, + anchor_out, + current_adaptive_state_lms, + peak_value, + target_compression_power, + normalized_target_gamut_mode, + resolved_gamut_enforcement, + post_compression_mode); + } else if (resolved_gamut_enforcement + == PSYCHO25_GAMUT_ENFORCEMENT_NONE + || use_psychov17_gamut + || use_adaptive_contrast_fit) { + // No-gamut mode retains the direct per-channel LMS compressor. The + // PsychoV17 option deliberately starts from this same complete Test25 + // physical/hue result before its separate final primary-gamut map. + output_lms = psycho25_ApplyPhysicalPerConePath( + contrast_lms, + signed_direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + contrast_power, + target_cone_response, + hue_method, + observer_gamut_mode); + } else if (hull_method == PSYCHO25_HULL_METHOD_TARGET_RGB_CLIP) { + // Clip remains the literal component-clamp comparison applied to the + // ordinary physical/Graph result. It is intentionally distinct from the + // independent post-contrast experiments above. + output_lms = psycho25_ApplyPhysicalPerConePath( + contrast_lms, + signed_direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + contrast_power, + target_cone_response, + hue_method, + observer_gamut_mode); + float3 post_target_rgb = psycho25_TargetRGBFromLMS( + output_lms, + normalized_target_gamut_mode); + if ((resolved_gamut_enforcement + & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0) { + post_target_rgb = max(post_target_rgb, 0.f.xxx); + } + if ((resolved_gamut_enforcement + & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0) { + post_target_rgb = min(post_target_rgb, peak_value.xxx); + } + output_lms = psycho25_LMSFromTargetRGB( + post_target_rgb, + normalized_target_gamut_mode); + } else { + // Target-plane mode uses the requested primary and/or peak constraints. + // The per-channel curve remains the direct output compressor only in the + // disabled branch above. Here the complete no-gamut result supplies only + // the adaptive-MB trajectory; its scale is discarded before target-plane + // correction, so it is not a second output curve. Guidance direction and + // its cone-response state remain separate from physical target magnitude. + float target_peak_yf = psycho25_SignedYfFromLMS(target_lms_peak); + float resolved_guidance_peak_yf = psycho25_ResolveGuidancePeakYf( + target_peak_yf, + guidance_peak_scale); + float3 guidance_lms_peak = + target_lms_peak * (resolved_guidance_peak_yf / target_peak_yf); + float guidance_compression_power = target_compression_power; + if (compression == PSYCHO25_AUTO_COMPRESSION_SENTINEL) { + guidance_compression_power = + psycho25_AutoCompressionFromCenteredReferenceRange( + psycho25_YfFromLMS(anchor_out), + resolved_guidance_peak_yf); + } + Psycho25ConeResponseParameters guidance_cone_response = + psycho25_PrepareConeResponseParameters( + anchor_out, + guidance_lms_peak, + contrast_power, + guidance_compression_power, + safe_encoded_response_power); + output_lms = psycho25_CompressTargetHull( + contrast_lms, + signed_direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + guidance_lms_peak, + contrast_power, + resolved_upper_plane_shoulder_power, + target_cone_response, + guidance_cone_response, + peak_value, + normalized_target_gamut_mode, + resolved_gamut_enforcement, + hue_method, + hull_method, + upper_hull_pivot, + canonical_pressure_pivot, + canonical_pressure_contrast, + canonical_pressure_h, + canonical_pressure_trade, + canonical_yf_bias_power, + observer_gamut_mode); + } + + if (post_compression_mode + == PSYCHO25_POST_COMPRESSION_SOURCE_BT709_RESIDUAL) { + output_lms = psycho25_RestoreSourceBT709ResidualDirection( + output_lms, + signed_direction_source_lms, + peak_value, + normalized_target_gamut_mode, + resolved_gamut_enforcement); + } else if (use_adaptive_contrast_fit) { + output_lms = psycho25_ApplyAdaptiveContrastFit( + output_lms, + contrast_lms, + signed_direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + contrast_power, + target_cone_response, + hue_method, + peak_value, + normalized_target_gamut_mode, + resolved_gamut_enforcement, + observer_gamut_mode); + } else if (use_psychov17_gamut) { + if (gamut_compression != 0.f) { + // Match PsychoV17's final device map exactly on the completed physical + // output before any experiment-specific peak operation. + output_lms = psycho25_GamutCompressLMSBoundAdaptive( + output_lms, + current_adaptive_state_lms, + normalized_target_gamut_mode, + gamut_compression); + } + if (use_psychov17_neutwo_peak) { + // Retain the physical/Graph trajectory as direction so positive hue rays + // still converge to white. Derive only scalar magnitude from the + // unbounded post-contrast signal after the same PsychoV17 primary map; + // applying Neutwo directly to the already bounded physical magnitude + // would cap neutral at peak/sqrt(2). + float3 target_rgb = psycho25_TargetRGBFromLMS( + output_lms, + normalized_target_gamut_mode); + float3 magnitude_lms = contrast_lms; + if (gamut_compression != 0.f) { + magnitude_lms = psycho25_GamutCompressLMSBoundAdaptive( + magnitude_lms, + current_adaptive_state_lms, + normalized_target_gamut_mode, + gamut_compression); + } + float3 magnitude_target_rgb = psycho25_TargetRGBFromLMS( + magnitude_lms, + normalized_target_gamut_mode); + if ((resolved_gamut_enforcement + & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0) { + target_rgb = max(target_rgb, 0.f.xxx); + magnitude_target_rgb = max(magnitude_target_rgb, 0.f.xxx); + } + float direction_max_channel = renodx::math::Max(abs(target_rgb)); + float magnitude_max_channel = renodx::math::Max( + abs(magnitude_target_rgb)); + float3 anchor_target_rgb = psycho25_TargetRGBFromLMS( + anchor_out, + normalized_target_gamut_mode); + float anchor_max_channel = min( + renodx::math::Max(abs(anchor_target_rgb)), + peak_value - PSYCHO25_EPSILON); + float anchor_input_max = renodx::tonemap::inverse::Neutwo( + anchor_max_channel, + peak_value); + float mapped_max_channel = renodx::tonemap::Neutwo( + magnitude_max_channel * renodx::math::DivideSafe( + anchor_input_max, + anchor_max_channel, + 1.f), + peak_value); + target_rgb *= renodx::math::DivideSafe( + mapped_max_channel, + direction_max_channel, + 1.f); + output_lms = psycho25_LMSFromTargetRGB( + target_rgb, + normalized_target_gamut_mode); + } else if (use_psychov17_nrg_white) { + // Retain the completed output's ACC-A scalar metric while replacing an + // over-peak selected-target RGB point with an in-cube point between its + // max-channel hue wall and peak D65 white. ACC-A here is an engineering + // scalar metric inherited from NRG Test7, not radiometric energy. + float3 target_rgb = max( + psycho25_TargetRGBFromLMS( + output_lms, + normalized_target_gamut_mode), + 0.f.xxx); + float max_target_channel = max( + target_rgb.x, + max(target_rgb.y, target_rgb.z)); + if (max_target_channel > peak_value) { + float3 target_bt2020 = normalized_target_gamut_mode == 0 + ? renodx::color::bt2020::from::BT709(target_rgb) + : target_rgb; + float3 hue_wall_target_rgb = target_rgb + * (peak_value / max_target_channel); + float3 hue_wall_bt2020 = normalized_target_gamut_mode == 0 + ? renodx::color::bt2020::from::BT709(hue_wall_target_rgb) + : hue_wall_target_rgb; + float scalar_output_raw; + target_bt2020 = renodx::tonemap::nrg::NRGTest7SolveWhiteSpillByScalarAccA( + hue_wall_bt2020, + peak_value, + renodx::tonemap::nrg::NRGTest7ScalarAccARaw( + target_bt2020, + peak_value), + scalar_output_raw); + target_rgb = normalized_target_gamut_mode == 0 + ? renodx::color::bt709::from::BT2020(target_bt2020) + : target_bt2020; + } + output_lms = psycho25_LMSFromTargetRGB( + target_rgb, + normalized_target_gamut_mode); + } + } + + return renodx::color::bt709::from::LMS(output_lms); +} + +} // namespace psychov +} // namespace tonemap +} // namespace renodx + +#endif // RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ diff --git a/src/games/elitedangerous/tonemap/tonemap.hlsli b/src/games/elitedangerous/tonemap/tonemap.hlsli index 5a5df5627..7a177c414 100644 --- a/src/games/elitedangerous/tonemap/tonemap.hlsli +++ b/src/games/elitedangerous/tonemap/tonemap.hlsli @@ -1,4 +1,5 @@ #include "../common.hlsli" +#include "./psychov25/test25.hlsli" #include "./test24.hlsli" static const float MID_GRAY_IN = 0.119121851127f; @@ -18,33 +19,6 @@ float3 ApplyAdaptiveMBPurity(float3 lms_input, float3 adaptive_neutral_lms, floa renodx::tonemap::psychov::psycho17_FromAdaptiveRelativeWeightedLMS(relative_weighted_out, adaptive_neutral_lms)); } -float Highlights(float x, float highlights, float mid_gray = 0.18f) { - if (highlights == 1.f) return x; - if (highlights > 1.f) { - return max(x, lerp(x, mid_gray * pow(x / mid_gray, highlights), min(x, 1.f))); - } else { - float b = mid_gray * pow(x / mid_gray, 2.f - highlights); - float t = min(x, 1.f); - return min(x, renodx::math::DivideSafe(x * x, lerp(x, b, t), x)); - } -} - -float Shadows(float x, float shadows, float mid_gray = 0.18f) { - if (shadows == 1.f) return x; - float ratio = max(renodx::math::DivideSafe(x, mid_gray, 0.f), 0.f); - float base_term = x * mid_gray; - float base_scale = renodx::math::DivideSafe(base_term, ratio, 0.f); - if (shadows > 1.f) { - float raised = x * (1.f + renodx::math::DivideSafe(base_term, pow(ratio, shadows), 0.f)); - float reference = x * (1.f + base_scale); - return max(x, x + (raised - reference)); - } else { - float lowered = x * (1.f - renodx::math::DivideSafe(base_term, pow(ratio, 2.f - shadows), 0.f)); - float reference = x * (1.f - base_scale); - return clamp(x + (lowered - reference), 0.f, x); - } -} - #define CONTRAST_AND_FLARE_GENERATOR(T) \ T ContrastAndFlare(T x, float contrast, float flare, T mid_gray_in = (T)0.18f, T mid_gray_out = (T)0.18f) { \ T x_normalized = x / mid_gray_in; \ @@ -114,193 +88,116 @@ float3 ApplyPurityGradingBT2020(float3 color_bt2020, float purity_scale, float h return renodx::color::bt2020::from::LMS(color_lms); } -float3 ApplyAnchoredAdaptationContrast( - float3 color, - float contrast, - float3 anchor_in = 0.18f, - float3 anchor_out = 0.18f, - float flare = 0.f, - float highlights = 1.f, - float shadows = 1.f) { - float3 ax = abs(color); - float3 normalized = ax / anchor_in; - float3 flare_ratio = 1.f + renodx::math::DivideSafe(flare, normalized + flare, 0.f); - float3 exponent = contrast * flare_ratio; - - float3 ax_n = pow(ax, exponent); - float3 s_n = pow(anchor_in, exponent); - float3 response_target = ax_n / (ax_n + s_n); - float3 response_baseline = ax / (ax + anchor_in); - float3 gain = renodx::math::DivideSafe(response_target, response_baseline, 0.f); - - float3 contrasted_normalized = ax * gain / anchor_in; - - if (highlights != 1.f) { - float3 highlight_distance = max(contrasted_normalized - 1.f, 0.f); - contrasted_normalized += highlight_distance * (pow(1.f + highlight_distance * highlight_distance, (highlights - 1.f) / 2.f) - 1.f); - } - - if (shadows != 1.f) { - float3 shadow_distance = max(1.f - contrasted_normalized, 0.f); - contrasted_normalized *= pow(1.f + shadow_distance * shadow_distance * shadow_distance, shadows - 1.f); - } - - return renodx::math::CopySign(contrasted_normalized * anchor_out, color); +float3 ComputeCInfinityTransition(float3 position) { + position = saturate(position); + return 1.f / (1.f + exp2((1.f - 2.f * position) / (position * (1.f - position)))); } -float3 ApplyAnchoredPowerContrast( +// Monotonic and C-infinity continuous anchored tonal grading +float3 ApplyAnchoredTonalGrading( float3 color, - float contrast, float3 anchor_in = 0.18f, float3 anchor_out = 0.18f, + float contrast = 1.f, float flare = 0.f, + float highlight_contrast = 1.f, + float shadow_contrast = 1.f, float highlights = 1.f, float shadows = 1.f) { - float3 ax = abs(color); - float3 normalized = ax / anchor_in; - float3 flare_ratio = 1.f + renodx::math::DivideSafe(flare, normalized + flare, 0.f); - - float3 contrasted_normalized = pow(normalized, contrast * flare_ratio); - - if (highlights != 1.f) { - float3 highlight_distance = max(contrasted_normalized - 1.f, 0.f); - contrasted_normalized += highlight_distance * (pow(1.f + highlight_distance * highlight_distance, (highlights - 1.f) / 2.f) - 1.f); - } - - if (shadows != 1.f) { - float3 shadow_distance = max(1.f - contrasted_normalized, 0.f); - contrasted_normalized *= pow(1.f + shadow_distance * shadow_distance * shadow_distance, shadows - 1.f); + [branch] + if (contrast == 1.f + && flare == 0.f + && highlight_contrast == 1.f + && shadow_contrast == 1.f + && highlights == 1.f + && shadows == 1.f + && all(anchor_in == anchor_out)) { + return color; } - return renodx::math::CopySign(contrasted_normalized * anchor_out, color); -} - -// Exact power contrast through the anchor, then C2 divisive normalization of -// highlight contrast displacement to a maximum magnitude of one stop. -float3 ApplyAnchoredBoundedPowerContrast( - float3 color, - float contrast, - float3 anchor_in = 0.18f, - float3 anchor_out = 0.18f, - float flare = 0.f, - float highlights = 1.f, - float shadows = 1.f) { float3 ax = abs(color); float3 normalized = ax / anchor_in; - float3 exponent = contrast; + float3 contrasted_normalized = normalized; - if (flare > 0.f) { - float3 shadow_weight = saturate(1.f - normalized); - shadow_weight *= shadow_weight; - exponent *= 1.f + flare * shadow_weight / (normalized + flare); - } - - float3 input_stops = log2(normalized); - float3 highlight_stops = max(input_stops, 0.f); - float3 contrast_displacement = (contrast - 1.f) * highlight_stops; - float3 normalized_displacement = contrast_displacement * rsqrt(mad(contrast_displacement, contrast_displacement, 1.f)); - float3 output_stops = mad(exponent, min(input_stops, 0.f), highlight_stops + normalized_displacement); - float3 contrasted_normalized = exp2(output_stops); - - if (highlights != 1.f) { - float3 highlight_distance = max(contrasted_normalized - 1.f, 0.f); - contrasted_normalized += highlight_distance * (pow(1.f + highlight_distance * highlight_distance, (highlights - 1.f) / 2.f) - 1.f); - } - - if (shadows != 1.f) { - float3 shadow_distance = max(1.f - contrasted_normalized, 0.f); - contrasted_normalized *= pow(1.f + shadow_distance * shadow_distance * shadow_distance, shadows - 1.f); - } - - return renodx::math::CopySign(contrasted_normalized * anchor_out, color); -} + // Power contrast and shadow flare, optionally bounding contrast on highlights. + [branch] + if (contrast != 1.f || flare > 0.f) { + float3 exponent = contrast; + + [branch] + if (flare > 0.f) { + float3 shadow_distance = saturate(1.f - normalized); + float3 flat_shadow_weight = exp2(-normalized / shadow_distance); + exponent *= mad(flat_shadow_weight, flare / (normalized + flare), 1.f); + } -// Exact power contrast through the anchor to every derivative, with C-infinity -// flare and grading joins, then smoothly bounds highlight displacement to one stop. -float3 ApplyAnchoredCInfinityBoundedPowerContrast( - float3 color, - float contrast, - float3 anchor_in = 0.18f, - float3 anchor_out = 0.18f, - float flare = 0.f, - float highlights = 1.f, - float shadows = 1.f) { - float3 ax = abs(color); - float3 normalized = ax / anchor_in; - float3 exponent = contrast; +#if 1 + float3 input_stops = log2(normalized); + float3 highlight_stops = max(input_stops, 0.f); + float3 output_highlight_stops = highlight_stops; + + [branch] + if (contrast != 1.f) { + float3 contrast_displacement = (contrast - 1.f) * highlight_stops; + float3 displacement_magnitude = abs(contrast_displacement); + output_highlight_stops += contrast_displacement / mad(displacement_magnitude, exp2(-1.f / displacement_magnitude), 1.f); + } - [branch] - if (flare > 0.f) { - float3 shadow_distance = saturate(1.f - normalized); - float3 flat_shadow_weight = exp2(-normalized / shadow_distance); - exponent *= mad(flat_shadow_weight, flare / (normalized + flare), 1.f); + contrasted_normalized = exp2(mad(exponent, min(input_stops, 0.f), output_highlight_stops)); +#else + contrasted_normalized = pow(normalized, exponent); +#endif } - float3 input_stops = log2(normalized); - float3 highlight_stops = max(input_stops, 0.f); - float3 contrast_displacement = (contrast - 1.f) * highlight_stops; - float3 displacement_magnitude = abs(contrast_displacement); - float3 bounded_displacement = contrast_displacement / mad(displacement_magnitude, exp2(-1.f / displacement_magnitude), 1.f); - float3 output_stops = mad(exponent, min(input_stops, 0.f), highlight_stops + bounded_displacement); - float3 contrasted_normalized = exp2(output_stops); - + // broad highlight contrast. [branch] - if (highlights != 1.f) { + if (highlight_contrast != 1.f) { float3 highlight_distance = max(contrasted_normalized - 1.f, 0.f); float3 highlight_distance_squared = highlight_distance * highlight_distance; float3 flat_highlight_distance = (1.f + highlight_distance_squared) * exp2(-1.f / highlight_distance_squared); - contrasted_normalized += highlight_distance * (pow(1.f + flat_highlight_distance, (highlights - 1.f) / 2.f) - 1.f); + contrasted_normalized += highlight_distance * (pow(1.f + flat_highlight_distance, 0.5f * (highlight_contrast - 1.f)) - 1.f); } + // broad shadow contrast. [branch] - if (shadows != 1.f) { + if (shadow_contrast != 1.f) { float3 shadow_distance = saturate(1.f - contrasted_normalized); float3 shadow_distance_squared = shadow_distance * shadow_distance; float3 flat_shadow_distance = shadow_distance_squared * shadow_distance * exp2(1.f - 1.f / shadow_distance_squared); - contrasted_normalized *= pow(1.f + flat_shadow_distance, shadows - 1.f); + contrasted_normalized *= pow(1.f + flat_shadow_distance, shadow_contrast - 1.f); } - return renodx::math::CopySign(contrasted_normalized * anchor_out, color); -} + // Mirror offsets about the anchor over the declared stop range. + [branch] + if (highlights != 1.f || shadows != 1.f) { + static const float TONAL_OFFSET_START_STOPS = 1.f; + static const float TONAL_OFFSET_END_STOPS = 8.f; + static const float TONAL_OFFSET_INVERSE_RANGE_STOPS = 1.f / (TONAL_OFFSET_END_STOPS - TONAL_OFFSET_START_STOPS); + + float3 tonal_stops = log2(contrasted_normalized); + float3 tonal_displacement = 0.f; + + [branch] + if (highlights != 1.f) { + float highlight_adjustment = highlights - 1.f; + float highlight_displacement = highlight_adjustment * mad(1.5f, abs(highlight_adjustment), 0.5f); + float3 highlight_weight = ComputeCInfinityTransition((tonal_stops - TONAL_OFFSET_START_STOPS) * TONAL_OFFSET_INVERSE_RANGE_STOPS); + tonal_displacement = mad(highlight_displacement, highlight_weight, tonal_displacement); + } -/// Identity through anchor; then approaches peak monotonically and concave down. -/// The anchor join is C2 continuous. Requires anchor < peak and compression_strength >= 1. -#define APPLYANCHOREDCUBICSHOULDER_GENERATOR(T) \ - T ApplyAnchoredCubicShoulder(T color, T peak, T anchor, float compression_strength) { \ - T shoulder_range = peak - anchor; \ - T distance_from_anchor = max(color - anchor, (T)0.f); \ - T weighted_distance = compression_strength * distance_from_anchor; \ - T response_numerator = distance_from_anchor * (shoulder_range + weighted_distance); \ - T response_denominator = mad( \ - shoulder_range, shoulder_range, weighted_distance * (shoulder_range + distance_from_anchor)); \ - return mad(shoulder_range, response_numerator / response_denominator, color - distance_from_anchor); \ - } + [branch] + if (shadows != 1.f) { + float shadow_adjustment = shadows - 1.f; + float shadow_displacement = shadow_adjustment * mad(1.5f, abs(shadow_adjustment), 0.5f); + float3 shadow_weight = ComputeCInfinityTransition((-TONAL_OFFSET_START_STOPS - tonal_stops) * TONAL_OFFSET_INVERSE_RANGE_STOPS); + tonal_displacement = mad(shadow_displacement, shadow_weight, tonal_displacement); + } -/// Identity through anchor; reaches peak at clip, then remains flat. -/// Monotonic, concave down, and C2 when clip meets the calculated minimum. -#define APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR(T) \ - T ApplyAnchoredCubicShoulder(T color, T peak, T anchor, float compression_strength, T clip) { \ - T shoulder_range = peak - anchor; \ - T distance_from_anchor = max(color - anchor, (T)0.f); \ - T input_range = clip - anchor; \ - T clipped_distance = min(distance_from_anchor, input_range); \ - T clip_position = clipped_distance / input_range; \ - T clip_position_squared = clip_position * clip_position; \ - T clip_position_cubed = clip_position_squared * clip_position; \ - T residual_weight = (T)1.f - clip_position_cubed * mad(clip_position, mad((T)6.f, clip_position, (T) - 15.f), (T)10.f); \ - T weighted_distance = compression_strength * clipped_distance; \ - T response_numerator = clipped_distance * (shoulder_range + weighted_distance); \ - T remaining_distance = shoulder_range * mad(compression_strength - 1.f, clipped_distance, shoulder_range); \ - T response_denominator = mad(residual_weight, remaining_distance, response_numerator); \ - return mad(shoulder_range, response_numerator / response_denominator, color - distance_from_anchor); \ + contrasted_normalized *= exp2(tonal_displacement); } -APPLYANCHOREDCUBICSHOULDER_GENERATOR(float) -APPLYANCHOREDCUBICSHOULDER_GENERATOR(float3) -APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR(float) -APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR(float3) -#undef APPLYANCHOREDCUBICSHOULDER_GENERATOR -#undef APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR + return renodx::math::CopySign(contrasted_normalized * anchor_out, color); +} /// Identity through anchor to every derivative; then approaches peak /// monotonically and concave down. Requires anchor < peak and compression_strength >= 1. @@ -323,6 +220,369 @@ float ApplyAnchoredCInfinityShoulderMaxChannelScale(float3 color, float peak, fl return renodx::math::DivideSafe(compressed_max, max_channel, 1.f); } +/// Identity at and below anchor; C-infinity generalized Naka-Rushton above it. +/// Requires anchor < peak, compression_power > 1, and 0 < response_coefficient <= 1. +#define APPLY_CINFINITY_NAKA_RUSHTON_GENERATOR(T) \ + T ApplyCInfinityNakaRushton(T color, T peak, T anchor, float compression_power = 1.f, float response_coefficient = 0.001f) { \ + float inverse_compression_power = rcp(compression_power); \ + float flat_response_numerator = -1.f / log(2.f) * response_coefficient; \ + T shoulder_range = peak - anchor; \ + T distance_from_anchor = max(color - anchor, (T)0.f); \ + T position = distance_from_anchor / shoulder_range; \ + T position_power = pow(position, compression_power); \ + T flat_response = exp2(flat_response_numerator * rcp(mad(position_power, position_power, position_power))); \ + T response_scale = pow(mad(position_power, flat_response, (T)1.f), -inverse_compression_power); \ + return mad(distance_from_anchor, response_scale, color - distance_from_anchor); \ + } +APPLY_CINFINITY_NAKA_RUSHTON_GENERATOR(float) +APPLY_CINFINITY_NAKA_RUSHTON_GENERATOR(float3) +#undef APPLY_CINFINITY_NAKA_RUSHTON_GENERATOR + +// Fixed PsychoV25 target-hull path: Fast60 hue guidance, Reference Scale, +// full BT.2020 lower/upper-plane enforcement, and a black upper-hull pivot. +float3 CompressPsychoV25ReferenceScaleHull( + float3 desired_lms, + float3 direction_source_lms, + float3 adaptive_state_lms, + float3 background_state_lms, + float3 target_lms_peak, + float source_direction_recovery_strength, + float naka_rushton_compression, + float cinfinity_shoulder_compression, + int white_curve_mode, + float cone_response_exponent, + float peak_value) { + float3 desired_weighted_lms = renodx::color::macleod_boynton::WeighLMS(desired_lms); + float desired_yf = desired_weighted_lms.x + desired_weighted_lms.y; + if (desired_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { + return 0.f.xxx; + } + + float adaptive_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(adaptive_state_lms); + float background_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(background_state_lms); + float target_peak_yf = renodx::tonemap::psychov::psycho25_SignedYfFromLMS(target_lms_peak); + float3 physical_compressed_lms; + [branch] + if (white_curve_mode == 1) { + renodx::tonemap::psychov::Psycho25ConeResponseParameters cone_response = + renodx::tonemap::psychov::psycho25_PrepareConeResponseParameters( + background_state_lms, + target_lms_peak, + cone_response_exponent, + naka_rushton_compression, + 1.f); + renodx::tonemap::psychov::Psycho25ConeResponseState response_state = + renodx::tonemap::psychov::psycho25_BuildConeResponseState( + desired_lms, + cone_response); + float3 normalized_response = response_state.encoded_response + / (abs(response_state.encoded_response) + + response_state.encoded_peak_offset); + float3 compressed_response = cone_response.inverse_compression_power == 1.f + ? normalized_response + : renodx::math::SignPow( + normalized_response, + cone_response.inverse_compression_power); + physical_compressed_lms = target_lms_peak * compressed_response; + } else { + physical_compressed_lms = ApplyAnchoredCInfinityShoulder( + desired_lms, + target_lms_peak, + background_state_lms, + cinfinity_shoulder_compression); + } + float authored_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(physical_compressed_lms); + if (authored_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { + return 0.f.xxx; + } + + float3 safe_adaptive_state_lms = max( + adaptive_state_lms, + renodx::tonemap::psychov::PSYCHO25_EPSILON.xxx); + float2 adapted_neutral_mb = renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 authored_mb = renodx::color::macleod_boynton::from::WeightedLMS( + renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( + physical_compressed_lms, + adaptive_state_lms)); + float3 source_mb = renodx::color::macleod_boynton::from::WeightedLMS( + renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( + direction_source_lms, + adaptive_state_lms)); + + // Fast60: retain physical radius and use the angular midpoint between the + // source direction and the raw per-cone-compressed direction. + float2 authored_offset = authored_mb.xy - adapted_neutral_mb; + float2 source_offset = source_mb.xy - adapted_neutral_mb; + float authored_radius2 = dot(authored_offset, authored_offset); + float source_radius2 = dot(source_offset, source_offset); + if (authored_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON + && source_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON) { + float2 source_direction = source_offset * rsqrt(source_radius2); + float2 compressed_direction = authored_offset * rsqrt(authored_radius2); + float2 output_direction = lerp( + source_direction, + compressed_direction, + 1.f - renodx::tonemap::psychov::PSYCHO25_HUE_AMPLITUDE); + float output_direction2 = dot(output_direction, output_direction); + if (output_direction2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON) { + authored_mb.xy = adapted_neutral_mb + + output_direction * rsqrt(output_direction2) * sqrt(authored_radius2); + authored_offset = authored_mb.xy - adapted_neutral_mb; + authored_radius2 = dot(authored_offset, authored_offset); + } + } + + float authored_radius = sqrt(authored_radius2); + float2 authored_direction = authored_offset * rsqrt(authored_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); + + // Reference Scale source-direction recovery keeps collapsing saturated + // highlights from rotating through an unrelated hue on their way to white. + [branch] + if (source_direction_recovery_strength > 0.f) { + float source_radius = sqrt(source_radius2); + float2 source_direction = source_offset * rsqrt(source_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); + float source_radius_support = + renodx::tonemap::psychov::psycho25_TargetLowerPlaneRadiusForDirection( + source_direction, + adapted_neutral_mb, + adaptive_state_lms, + 1); + float source_direction_support_radius = + renodx::tonemap::psychov::PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY + * source_radius_support + * renodx::math::DivideSafe( + source_radius, + sqrt(source_radius2 + source_radius_support * source_radius_support), + 0.f); + float radius_normalization = max( + max(authored_radius, source_direction_support_radius), + renodx::tonemap::psychov::PSYCHO25_EPSILON); + float authored_weight = pow( + authored_radius / radius_normalization, + renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); + float source_direction_support_weight = pow( + source_direction_support_radius / radius_normalization, + renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); + float source_hue_support = + renodx::tonemap::psychov::PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION + * source_radius_support; + float source_hue_confidence = renodx::math::DivideSafe( + source_radius2, + source_radius2 + source_hue_support * source_hue_support, + 0.f); + float source_collapse_weight = renodx::math::DivideSafe( + source_direction_support_weight, + authored_weight + source_direction_support_weight, + 0.f); + float source_direction_weight = source_direction_recovery_strength + * (1.f - (1.f - source_hue_confidence) * (1.f - source_collapse_weight)); + float2 combined_direction = lerp( + authored_direction, + source_direction, + source_direction_weight); + combined_direction *= rsqrt( + dot(combined_direction, combined_direction) + + renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON); + authored_direction = combined_direction; + authored_offset = authored_direction * authored_radius; + authored_mb.xy = adapted_neutral_mb + authored_offset; + } + + // Discard the trajectory's carried scale, preserving only its authored + // adaptive-MB direction and radius before solving the BT.2020 hull. + float trajectory_yf_for_normalization = authored_mb.z + * (authored_mb.x * safe_adaptive_state_lms.x + + (1.f - authored_mb.x) * safe_adaptive_state_lms.y); + float3 unit_yf_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( + float3( + authored_mb.xy, + renodx::math::DivideSafe( + authored_mb.z, + trajectory_yf_for_normalization, + 0.f)), + adaptive_state_lms); + float3 neutral_lms = adaptive_state_lms / adaptive_yf; + + // Reference Scale lower-plane compression keeps the authored hue ray inside + // the nonnegative BT.2020 primary half-spaces without a component clamp. + if (authored_radius > renodx::tonemap::psychov::PSYCHO25_EPSILON) { + float3 neutral_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(neutral_lms, 1); + float3 current_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); + float current_boundary_fraction = + renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( + current_target_rgb, + neutral_target_rgb); + float current_radius_scale = + renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( + current_boundary_fraction); + + authored_direction = authored_offset / authored_radius; + float containment_reference_radius = max( + authored_radius, + length(source_mb.xy - adapted_neutral_mb)); + float3 reference_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( + float3( + adapted_neutral_mb + + authored_direction * containment_reference_radius, + 1.f), + adaptive_state_lms); + reference_lms /= renodx::tonemap::psychov::psycho25_YfFromLMS(reference_lms); + float3 reference_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(reference_lms, 1); + float reference_boundary_fraction = + renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( + reference_target_rgb, + neutral_target_rgb); + float reference_radius_scale = + renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( + reference_boundary_fraction); + + float trajectory_fraction = authored_radius / containment_reference_radius; + float release_progress = saturate( + trajectory_fraction + / renodx::tonemap::psychov::PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION); + float neutral_scale = min(1.f, 4.f * reference_radius_scale); + float release_weight = 1.f - release_progress; + float radius_scale = min( + lerp( + reference_radius_scale, + neutral_scale, + release_weight * release_weight), + current_radius_scale); + unit_yf_lms = lerp(neutral_lms, unit_yf_lms, radius_scale); + } + + // Black-pivot upper-plane shoulder along the contained BT.2020 hue ray. + float3 unit_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); + float max_target_channel = max( + unit_target_rgb.x, + max(unit_target_rgb.y, unit_target_rgb.z)); + float directional_yf_limit = peak_value / max_target_channel; + float normalized_input = desired_yf * renodx::math::DivideSafe(target_peak_yf, directional_yf_limit, 1.f); + float normalized_output; + [branch] + if (white_curve_mode == 1) { + normalized_output = ApplyCInfinityNakaRushton( + normalized_input, + target_peak_yf, + background_yf, + naka_rushton_compression, + 0.001f); + } else { + normalized_output = ApplyAnchoredCInfinityShoulder( + normalized_input, + target_peak_yf, + background_yf, + cinfinity_shoulder_compression); + } + float output_yf = normalized_output * renodx::math::DivideSafe(directional_yf_limit, target_peak_yf, 1.f); + return unit_yf_lms * output_yf; +} + +float3 ApplyCustomPsychoV25ToneMap( + float3 bt709_linear_input, + float peak_value, + float highlights, + float shadows, + float cone_response_exponent, + float flare, + float purity_scale, + float highlight_saturation, + float dechroma, + float source_direction_recovery_strength = 0.f, + float3 current_adaptive_state_bt709 = 0.18f, + float3 current_background_state_bt709 = 0.18f, + int white_curve_mode = 0, + float naka_rushton_compression = 0.f, + float cinfinity_shoulder_compression = 1.5f) { + float3 finite_bt709_input = renodx::math::ZeroNaN(bt709_linear_input); + finite_bt709_input = renodx::math::Select( + isinf(finite_bt709_input), + renodx::math::CopySign(65504.f.xxx, finite_bt709_input), + finite_bt709_input); + + float3 lms_in = renodx::color::lms::from::BT709(finite_bt709_input); + float3 current_adaptive_state_lms = + renodx::color::lms::from::BT709(current_adaptive_state_bt709); + float3 current_background_state_lms = + renodx::color::lms::from::BT709(current_background_state_bt709); + float3 target_lms_peak = renodx::color::lms::from::BT709(peak_value.xxx); + + if (dechroma != 0.f || highlight_saturation != 1.f) { + float luminance = renodx::color::yf::from::LMS(lms_in); + float neutral_luminance = renodx::color::yf::from::LMS(current_adaptive_state_lms); + + // Ramp purity grading over 2.75 decades above the adaptive neutral. + static const float INVERSE_HIGHLIGHT_RANGE_STOPS = 1.f / (2.75f * log2(10.f)); + static const float HIGHLIGHT_ROLLOFF_CUBIC_BLEND = 0.5f; + static const float HIGHLIGHT_PURITY_STRENGTH = 2.f / 3.f; + + float luminance_from_neutral = max(luminance, neutral_luminance) / neutral_luminance; + float rolloff_position = saturate(log2(luminance_from_neutral) * INVERSE_HIGHLIGHT_RANGE_STOPS); + float rolloff_position_squared = rolloff_position * rolloff_position; + float rolloff = rolloff_position_squared * rolloff_position * mad(rolloff_position, mad(6.f, rolloff_position, -15.f), 10.f); + + // Base smootherstep brings dechroma into the midtones while remaining monotonic and C2. + if (dechroma != 0.f) { + purity_scale *= mad(-dechroma, rolloff, 1.f); + } + + // Blend smootherstep squared and cubed for a later, gentler C2 progression. + if (highlight_saturation != 1.f) { + float highlight_rolloff = rolloff * rolloff * mad(HIGHLIGHT_ROLLOFF_CUBIC_BLEND, rolloff, 1.f - HIGHLIGHT_ROLLOFF_CUBIC_BLEND); + purity_scale *= mad(highlight_saturation - 1.f, highlight_rolloff * HIGHLIGHT_PURITY_STRENGTH, 1.f); + } + } + + float3 contrast_input = renodx::tonemap::psychov::psycho25_ApplyAdaptiveMBPurity( + lms_in, + current_adaptive_state_lms, + purity_scale); + float3 contrast_lms = ApplyAnchoredTonalGrading( + contrast_input, + current_adaptive_state_lms, + current_background_state_lms, + cone_response_exponent, + flare, + 1.f, + 1.f, + highlights, + shadows); + + float naka_rushton_compression_power = naka_rushton_compression; + if (white_curve_mode == 1) { + if (naka_rushton_compression == renodx::tonemap::psychov::PSYCHO25_AUTO_COMPRESSION_SENTINEL) { + naka_rushton_compression_power = renodx::tonemap::psychov::psycho25_AutoCompressionFromCenteredReferenceRange( + renodx::tonemap::psychov::psycho25_YfFromLMS(current_background_state_lms), + renodx::tonemap::psychov::psycho25_YfFromLMS(target_lms_peak)); + } + naka_rushton_compression_power = max( + naka_rushton_compression_power, + renodx::tonemap::psychov::PSYCHO25_MIN_MANUAL_COMPRESSION); + } + + float3 output_lms = CompressPsychoV25ReferenceScaleHull( + contrast_lms, + contrast_input, + current_adaptive_state_lms, + current_background_state_lms, + target_lms_peak, + source_direction_recovery_strength, + naka_rushton_compression_power, + cinfinity_shoulder_compression, + white_curve_mode, + cone_response_exponent, + peak_value); + return renodx::color::bt709::from::LMS(output_lms); +} + /// Elite Dangerous vanilla SDR tonemapper. /// Output is in gamma space. #define APPLY_VANILLA_TONEMAP_GENERATOR(T) \ @@ -446,75 +706,21 @@ float3 ApplyPostLUTToneMap(float3 untonemapped_gamma) { tonemapped = ApplyAnchoredCInfinityShoulder(untonemapped, RENODX_PEAK_WHITE_NITS / RENODX_DIFFUSE_WHITE_NITS, MID_GRAY_OUT, 1.5f); tonemapped = renodx::color::bt709::from::BT2020(tonemapped); } else { // Custom - float3 untonemapped_lms = max(0, renodx::color::lms::from::BT709(untonemapped)); - float3 current_adaptive_state_lms = renodx::color::lms::from::BT709(MID_GRAY_IN); - float3 desired_background_state_lms = renodx::color::lms::from::BT709(MID_GRAY_OUT); - float3 peak_lms = renodx::color::lms::from::BT2020(RENODX_PEAK_WHITE_NITS / RENODX_DIFFUSE_WHITE_NITS); - -// Apply anchored LMS contrast. -#if 0 - float3 graded_lms = ApplyAnchoredAdaptationContrast(untonemapped_lms, - (1.745f) * RENODX_TONE_MAP_CONTRAST, - current_adaptive_state_lms, desired_background_state_lms, - 0.10f * pow(0.77f, 10.f) + 0.10f * pow(RENODX_TONE_MAP_FLARE, 10.f), - RENODX_TONE_MAP_HIGHLIGHTS, - RENODX_TONE_MAP_SHADOWS); -#else - float3 graded_lms = ApplyAnchoredCInfinityBoundedPowerContrast(untonemapped_lms, - (1.55f) * RENODX_TONE_MAP_CONTRAST, - current_adaptive_state_lms, desired_background_state_lms, - 0.10f * pow(0.85f, 10.f) + 0.10f * pow(RENODX_TONE_MAP_FLARE, 10.f), - RENODX_TONE_MAP_HIGHLIGHTS, - RENODX_TONE_MAP_SHADOWS); -#endif - - // Apply LMS luminance and purity grading. - graded_lms = ApplyPurityGradingLMS(graded_lms, - RENODX_TONE_MAP_SATURATION, - RENODX_TONE_MAP_HIGHLIGHT_SATURATION, - RENODX_TONE_MAP_DECHROMA, - desired_background_state_lms); - -#if 1 - // Restore the pre-contrast adaptive-MB hue direction before compression, - // using input midgray rather than peak as full to-white progress. - float to_white_progress = renodx::math::DivideSafe( - renodx::color::yf::from::LMS(untonemapped_lms), - renodx::color::yf::from::LMS(current_adaptive_state_lms), - 1.f); - float3 hue_corrected_lms = renodx_custom::tonemap::psychov::psycho24_ApplyManualHueDirection( - graded_lms, - untonemapped_lms, - current_adaptive_state_lms, - to_white_progress); - float3 shoulder_input_lms = lerp(graded_lms, hue_corrected_lms, 0.3f); -#else - float3 shoulder_input_lms = graded_lms; -#endif - float3 compressed_lms = ApplyAnchoredCInfinityShoulder( - shoulder_input_lms, - peak_lms, - desired_background_state_lms, - 1.5f); - - // Test24 adaptive weighted-LMS compression against the BT.2020 boundary. - float3 display_scaled_relative_weighted = renodx_custom::tonemap::psychov::psycho24_ToAdaptiveRelativeWeightedLMS( - compressed_lms, - desired_background_state_lms); - display_scaled_relative_weighted = renodx::color::gamut::GamutCompressWeightedLMSCoreRGBBoundFromAdaptiveWeightedInput( - display_scaled_relative_weighted, - desired_background_state_lms, - renodx::color::macleod_boynton::BT2020_TO_LMS_WEIGHTED_MAT, - 1.f); - - float3 gamut_mapped_bt709 = renodx::color::bt709::from::LMS( - renodx::color::macleod_boynton::UnweighLMS( - renodx_custom::tonemap::psychov::psycho24_FromAdaptiveRelativeWeightedLMS( - display_scaled_relative_weighted, - desired_background_state_lms))); - - tonemapped = gamut_mapped_bt709; + tonemapped = ApplyCustomPsychoV25ToneMap( + untonemapped, + RENODX_PEAK_WHITE_NITS / RENODX_DIFFUSE_WHITE_NITS, + RENODX_TONE_MAP_HIGHLIGHTS, + RENODX_TONE_MAP_SHADOWS, + 1.55f * RENODX_TONE_MAP_CONTRAST, + 0.10f * pow(0.85f, 10.f) + 0.10f * pow(RENODX_TONE_MAP_FLARE, 10.f), + RENODX_TONE_MAP_SATURATION, + RENODX_TONE_MAP_HIGHLIGHT_SATURATION, + RENODX_TONE_MAP_DECHROMA, + 0.f, + MID_GRAY_IN, + MID_GRAY_OUT, + 0, 1.f, 1.5f); } return renodx::color::gamma::EncodeSafe(tonemapped, 2.2f); From ebb5ee50a06d6cd2c1948f254a2560c757d9e2aa Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Tue, 11 Aug 2026 05:00:00 -0400 Subject: [PATCH 02/22] feat(deathstranding2): integrate PsychoV25 components into lms tonemap --- .../tonemap/psychov25/acc_dkl.hlsli | 354 ++ .../tonemap/psychov25/bleaching.hlsli | 95 + .../tonemap/psychov25/nrg.hlsli | 919 ++++ .../tonemap/psychov25/stockman.hlsli | 112 + .../tonemap/psychov25/test25.hlsli | 4085 +++++++++++++++++ .../deathstranding2/tonemap/tonemap.hlsli | 481 +- 6 files changed, 5988 insertions(+), 58 deletions(-) create mode 100644 src/games/deathstranding2/tonemap/psychov25/acc_dkl.hlsli create mode 100644 src/games/deathstranding2/tonemap/psychov25/bleaching.hlsli create mode 100644 src/games/deathstranding2/tonemap/psychov25/nrg.hlsli create mode 100644 src/games/deathstranding2/tonemap/psychov25/stockman.hlsli create mode 100644 src/games/deathstranding2/tonemap/psychov25/test25.hlsli diff --git a/src/games/deathstranding2/tonemap/psychov25/acc_dkl.hlsli b/src/games/deathstranding2/tonemap/psychov25/acc_dkl.hlsli new file mode 100644 index 000000000..97f8740a3 --- /dev/null +++ b/src/games/deathstranding2/tonemap/psychov25/acc_dkl.hlsli @@ -0,0 +1,354 @@ +#ifndef SRC_SHADERS_COLOR_ACC_DKL_HLSL_ +#define SRC_SHADERS_COLOR_ACC_DKL_HLSL_ + +#include "./stockman.hlsli" + +namespace renodx { +namespace color { + +namespace acc { +// Generic ACC algebra: +// - lms_white defines the opponent matrix coefficients (mc1, mc2) +// - lms_background defines the operating point that LMS is delta'd against +// - matrix overloads allow a fully folded fast path when white/background are fixed +// - weighted/unweighted LMS use the same algebra; the separate entry points make +// the caller's basis choice explicit +static const float EPSILON = 1e-6f; + +float3 SafeLMSWhite(float3 lms_white = 1) { + return max(abs(lms_white), EPSILON.xxx); +} + +float2 ParamsFromLMSWhite(float3 lms_white = 1) { + float3 white = SafeLMSWhite(lms_white); + return float2( + renodx::math::DivideSafe(white.x, white.y, 0), + renodx::math::DivideSafe(white.x + white.y, white.z, 0)); +} + +float2 ParamsFromWeightedLMSWhite(float3 lms_weighted_white = 1) { + return ParamsFromLMSWhite(lms_weighted_white); +} + +float3x3 LMSDeltaToACCMatrix(float3 lms_white = 1) { + float2 acc_params = ParamsFromLMSWhite(lms_white); + float mc1 = acc_params.x; + float mc2 = acc_params.y; + + return float3x3( + 1.00000000f, 1.00000000f, 0.00000000f, + 1.00000000f, -mc1, 0.00000000f, + -1.00000000f, -1.00000000f, mc2); +} + +float3x3 WeightedLMSDeltaToACCMatrix(float3 lms_weighted_white = 1) { + return LMSDeltaToACCMatrix(lms_weighted_white); +} + +float3x3 ACCToLMSDeltaMatrix(float3 lms_white = 1) { + float2 acc_params = ParamsFromLMSWhite(lms_white); + float mc1 = acc_params.x; + float mc2 = acc_params.y; + + float inv_lm = renodx::math::DivideSafe(1.f, 1.f + mc1, 0); + float inv_s = renodx::math::DivideSafe(1.f, mc2, 0); + + return float3x3( + mc1 * inv_lm, inv_lm, 0.00000000f, + inv_lm, -inv_lm, 0.00000000f, + inv_s, 0.00000000f, inv_s); +} + +float3x3 ACCToWeightedLMSDeltaMatrix(float3 lms_weighted_white = 1) { + return ACCToLMSDeltaMatrix(lms_weighted_white); +} + +float3 BiasFromLMSBackground(float3x3 lms_delta_to_acc_mat, float3 lms_background = 0) { + return -mul(lms_delta_to_acc_mat, lms_background); +} + +namespace from { +float3 LMSDelta(float3 delta_lms, float3 lms_white = 1) { + return mul(LMSDeltaToACCMatrix(lms_white), delta_lms); +} + +float3 LMSDelta(float3 delta_lms, float3x3 lms_delta_to_acc_mat) { + return mul(lms_delta_to_acc_mat, delta_lms); +} + +float3 WeightedLMSDelta(float3 delta_lms_weighted, float3 lms_weighted_white = 1) { + return mul(WeightedLMSDeltaToACCMatrix(lms_weighted_white), delta_lms_weighted); +} + +float3 WeightedLMSDelta(float3 delta_lms_weighted, float3x3 weighted_lms_delta_to_acc_mat) { + return mul(weighted_lms_delta_to_acc_mat, delta_lms_weighted); +} + +float3 LMS(float3 lms, float3 lms_white = 1, float3 lms_background = 0) { + return LMSDelta(lms - lms_background, lms_white); +} + +float3 LMS(float3 lms, float3x3 lms_to_acc_mat, float3 acc_bias = 0) { + return mul(lms_to_acc_mat, lms) + acc_bias; +} + +float3 WeightedLMS(float3 lms_weighted, float3 lms_weighted_white = 1, + float3 lms_weighted_background = 0) { + return WeightedLMSDelta(lms_weighted - lms_weighted_background, lms_weighted_white); +} + +float3 WeightedLMS(float3 lms_weighted, float3x3 weighted_lms_to_acc_mat, float3 acc_bias = 0) { + return mul(weighted_lms_to_acc_mat, lms_weighted) + acc_bias; +} +} // namespace from + +namespace to { +float3 LMSDelta(float3 acc_value, float3 lms_white = 1) { + return mul(ACCToLMSDeltaMatrix(lms_white), acc_value); +} + +float3 LMSDelta(float3 acc_value, float3x3 acc_to_lms_delta_mat) { + return mul(acc_to_lms_delta_mat, acc_value); +} + +float3 WeightedLMSDelta(float3 acc_value, float3 lms_weighted_white = 1) { + return mul(ACCToWeightedLMSDeltaMatrix(lms_weighted_white), acc_value); +} + +float3 WeightedLMSDelta(float3 acc_value, float3x3 acc_to_weighted_lms_delta_mat) { + return mul(acc_to_weighted_lms_delta_mat, acc_value); +} + +float3 LMS(float3 acc_value, float3 lms_white = 1, float3 lms_background = 0) { + return LMSDelta(acc_value, lms_white) + lms_background; +} + +float3 LMS(float3 acc_value, float3x3 acc_to_lms_delta_mat, float3 lms_background = 0) { + return mul(acc_to_lms_delta_mat, acc_value) + lms_background; +} + +float3 WeightedLMS(float3 acc_value, float3 lms_weighted_white = 1, + float3 lms_weighted_background = 0) { + return WeightedLMSDelta(acc_value, lms_weighted_white) + lms_weighted_background; +} + +float3 WeightedLMS(float3 acc_value, float3x3 acc_to_weighted_lms_delta_mat, + float3 lms_weighted_background = 0) { + return mul(acc_to_weighted_lms_delta_mat, acc_value) + lms_weighted_background; +} +} // namespace to +} // namespace acc + +namespace dkl { +namespace from { +float3 LMSDelta(float3 delta_lms, float3 lms_white = 1) { + return acc::from::LMSDelta(delta_lms, lms_white); +} + +float3 LMSDelta(float3 delta_lms, float3x3 lms_delta_to_dkl_mat) { + return acc::from::LMSDelta(delta_lms, lms_delta_to_dkl_mat); +} + +float3 WeightedLMSDelta(float3 delta_lms_weighted, float3 lms_weighted_white = 1) { + return acc::from::WeightedLMSDelta(delta_lms_weighted, lms_weighted_white); +} + +float3 WeightedLMSDelta(float3 delta_lms_weighted, float3x3 weighted_lms_delta_to_dkl_mat) { + return acc::from::WeightedLMSDelta(delta_lms_weighted, weighted_lms_delta_to_dkl_mat); +} + +float3 LMS(float3 lms, float3 lms_white = 1, float3 lms_background = 0) { + return acc::from::LMS(lms, lms_white, lms_background); +} + +float3 LMS(float3 lms, float3x3 lms_to_dkl_mat, float3 dkl_bias = 0) { + return acc::from::LMS(lms, lms_to_dkl_mat, dkl_bias); +} + +float3 WeightedLMS(float3 lms_weighted, float3 lms_weighted_white = 1, + float3 lms_weighted_background = 0) { + return acc::from::WeightedLMS(lms_weighted, lms_weighted_white, lms_weighted_background); +} + +float3 WeightedLMS(float3 lms_weighted, float3x3 weighted_lms_to_dkl_mat, float3 dkl_bias = 0) { + return acc::from::WeightedLMS(lms_weighted, weighted_lms_to_dkl_mat, dkl_bias); +} +} // namespace from + +namespace to { +float3 LMSDelta(float3 dkl_value, float3 lms_white = 1) { + return acc::to::LMSDelta(dkl_value, lms_white); +} + +float3 LMSDelta(float3 dkl_value, float3x3 dkl_to_lms_delta_mat) { + return acc::to::LMSDelta(dkl_value, dkl_to_lms_delta_mat); +} + +float3 WeightedLMSDelta(float3 dkl_value, float3 lms_weighted_white = 1) { + return acc::to::WeightedLMSDelta(dkl_value, lms_weighted_white); +} + +float3 WeightedLMSDelta(float3 dkl_value, float3x3 dkl_to_weighted_lms_delta_mat) { + return acc::to::WeightedLMSDelta(dkl_value, dkl_to_weighted_lms_delta_mat); +} + +float3 LMS(float3 dkl_value, float3 lms_white = 1, float3 lms_background = 0) { + return acc::to::LMS(dkl_value, lms_white, lms_background); +} + +float3 LMS(float3 dkl_value, float3x3 dkl_to_lms_delta_mat, float3 lms_background = 0) { + return acc::to::LMS(dkl_value, dkl_to_lms_delta_mat, lms_background); +} + +float3 WeightedLMS(float3 dkl_value, float3 lms_weighted_white = 1, + float3 lms_weighted_background = 0) { + return acc::to::WeightedLMS(dkl_value, lms_weighted_white, lms_weighted_background); +} + +float3 WeightedLMS(float3 dkl_value, float3x3 dkl_to_weighted_lms_delta_mat, + float3 lms_weighted_background = 0) { + return acc::to::WeightedLMS(dkl_value, dkl_to_weighted_lms_delta_mat, lms_weighted_background); +} +} // namespace to +} // namespace dkl + +namespace stockman { +namespace acc { +// Concrete Stockman ACC uses Stockman D65 as the white that defines the matrix. +// The optional background remains caller-controlled and defaults to zero delta. +float3 LMSWhite() { + return renodx::color::lms::from::WhiteD65(); +} + +float2 Params() { + return renodx::color::acc::ParamsFromLMSWhite(LMSWhite()); +} + +float3x3 LMSDeltaToACCMatrix() { + return renodx::color::acc::LMSDeltaToACCMatrix(LMSWhite()); +} + +float3x3 ACCToLMSDeltaMatrix() { + return renodx::color::acc::ACCToLMSDeltaMatrix(LMSWhite()); +} + +float3x3 LMSD65ToACCMatrix() { + return renodx::color::acc::LMSDeltaToACCMatrix(1); +} + +float3x3 ACCToLMSD65Matrix() { + return renodx::color::acc::ACCToLMSDeltaMatrix(1); +} + +namespace from { +float3 LMSDelta(float3 delta_lms) { + return renodx::color::acc::from::LMSDelta(delta_lms, stockman::acc::LMSDeltaToACCMatrix()); +} + +float3 LMS(float3 lms_abs, float3 lms_background = 0) { + return renodx::color::acc::from::LMS( + lms_abs, + stockman::acc::LMSDeltaToACCMatrix(), + renodx::color::acc::BiasFromLMSBackground( + stockman::acc::LMSDeltaToACCMatrix(), + lms_background)); +} + +float3 BT709(float3 bt709, float3 lms_background = 0) { + return LMS(lms::from::BT709(bt709), lms_background); +} + +float3 BT2020(float3 bt2020, float3 lms_background = 0) { + return LMS(lms::from::BT2020(bt2020), lms_background); +} + +float3 LMSD65(float3 lms_d65, float3 lms_background = 0) { + return renodx::color::acc::from::LMS( + lms_d65, + stockman::acc::LMSD65ToACCMatrix(), + renodx::color::acc::BiasFromLMSBackground( + stockman::acc::LMSD65ToACCMatrix(), + lms_background)); +} +} // namespace from + +namespace to { +float3 LMSDelta(float3 acc_value) { + return renodx::color::acc::to::LMSDelta(acc_value, stockman::acc::ACCToLMSDeltaMatrix()); +} + +float3 LMS(float3 acc_value, float3 lms_background = 0) { + return renodx::color::acc::to::LMS( + acc_value, + stockman::acc::ACCToLMSDeltaMatrix(), + lms_background); +} + +float3 BT709(float3 acc_value, float3 lms_background = 0) { + return bt709::from::LMS(LMS(acc_value, lms_background)); +} + +float3 BT2020(float3 acc_value, float3 lms_background = 0) { + return bt2020::from::LMS(LMS(acc_value, lms_background)); +} + +float3 LMSD65(float3 acc_value, float3 lms_background = 0) { + return renodx::color::acc::to::LMS( + acc_value, + stockman::acc::ACCToLMSD65Matrix(), + lms_background); +} +} // namespace to +} // namespace acc + +namespace dkl { +namespace from { +float3 LMSDelta(float3 delta_lms) { + return acc::from::LMSDelta(delta_lms); +} + +float3 LMS(float3 lms_abs, float3 lms_background = 0) { + return acc::from::LMS(lms_abs, lms_background); +} + +float3 BT709(float3 bt709, float3 lms_background = 0) { + return acc::from::BT709(bt709, lms_background); +} + +float3 BT2020(float3 bt2020, float3 lms_background = 0) { + return acc::from::BT2020(bt2020, lms_background); +} + +float3 LMSD65(float3 lms_d65, float3 lms_background = 0) { + return acc::from::LMSD65(lms_d65, lms_background); +} +} // namespace from + +namespace to { +float3 LMSDelta(float3 dkl_value) { + return acc::to::LMSDelta(dkl_value); +} + +float3 LMS(float3 dkl_value, float3 lms_background = 0) { + return acc::to::LMS(dkl_value, lms_background); +} + +float3 BT709(float3 dkl_value, float3 lms_background = 0) { + return acc::to::BT709(dkl_value, lms_background); +} + +float3 BT2020(float3 dkl_value, float3 lms_background = 0) { + return acc::to::BT2020(dkl_value, lms_background); +} + +float3 LMSD65(float3 dkl_value, float3 lms_background = 0) { + return acc::to::LMSD65(dkl_value, lms_background); +} +} // namespace to +} // namespace dkl +} // namespace stockman + +} // namespace color +} // namespace renodx + +#endif // SRC_SHADERS_COLOR_ACC_DKL_HLSL_ diff --git a/src/games/deathstranding2/tonemap/psychov25/bleaching.hlsli b/src/games/deathstranding2/tonemap/psychov25/bleaching.hlsli new file mode 100644 index 000000000..3b6236937 --- /dev/null +++ b/src/games/deathstranding2/tonemap/psychov25/bleaching.hlsli @@ -0,0 +1,95 @@ +#ifndef SRC_SHADERS_COLOR_BLEACHING_HLSL_ +#define SRC_SHADERS_COLOR_BLEACHING_HLSL_ + +#include "../common.hlsli" + +namespace renodx { +namespace color { +namespace bleaching { + +namespace rushton_henry { + +static const float CONE_HALF_BLEACH_TROLANDS = 20000.f; + +// One-sided availability limiter in adapted units. +// p(r) = 1 / (1 + r / r0) +// Source direction: same algebraic form as the steady-state cone bleaching law +// used by Rushton & Henry (1968), commonly written for fraction bleached as +// p_bleached(I) = I / (I + I0) +// with I in photopic trolands and I0 ~ 10^4.3 Td for cones. This helper uses +// the complementary fraction +// p_available(I) = 1 - p_bleached(I) = I0 / (I + I0) +// because the shader attenuates available cone drive rather than tracking the +// bleached fraction directly. +// Secondary source with the equation stated explicitly: +// Stockman, Henning, Smithson, & Rider (JOV 2018, 18(6):12), appendix note: +// "p = I / (I + I0)", with I0 = 10^4.3 Td, citing Rushton & Henry (1968). +float AvailabilityFromRelativeDrive(float relative_drive, float knee_ratio) { + return 1.f / (1.f + relative_drive / knee_ratio); +} + +// Absolute trolands form of the same availability law. +// p(I) = 1 / (1 + I / I0) +float AvailabilityFromTrolands(float retinal_illuminance_trolands, + float half_bleach_trolands = CONE_HALF_BLEACH_TROLANDS) { + return 1.f / (1.f + retinal_illuminance_trolands / half_bleach_trolands); +} + +float3 AvailabilityFromTrolands(float3 retinal_illuminance_trolands, + float half_bleach_trolands = CONE_HALF_BLEACH_TROLANDS) { + return float3( + AvailabilityFromTrolands(retinal_illuminance_trolands.x, half_bleach_trolands), + AvailabilityFromTrolands(retinal_illuminance_trolands.y, half_bleach_trolands), + AvailabilityFromTrolands(retinal_illuminance_trolands.z, half_bleach_trolands)); +} + +} // namespace rushton_henry + +// White-relative per-cone attenuation: +// - Keeps a white anchor at the same L+M level as the input. +// - Applies independent cone gains to LMS deltas around that anchor. +// Engineering interpretation: +// - The bleaching source law above constrains available pigment / sensitivity. +// - The specific "bleach toward white at the same carried achromatic level" +// behavior implemented here is the repo's rendering model for color signals, +// not a literal equation from Rushton & Henry. It is chosen so that strong +// bleaching suppresses cone-opponent excursions while preserving the +// achromatic anchor. +// - CVRL notes that bleaching also reduces effective photopigment density and +// therefore narrows spectral sensitivity without shifting lambda_max. This +// helper does not model that wavelength-dependent narrowing; it is a +// first-order scalar availability approximation intended for rendering. +// - CVRL also notes that a reliable S-cone half-bleaching constant has not +// been established. The shared cone knee used here is therefore an +// engineering approximation rather than a fully resolved per-cone +// physiological model. +float3 ApplyAvailabilityToLMSPerConeWhiteRelative(float3 lms, float3 availability_lms, + float3 white_lms) { + float y = lms.x + lms.y; + float white_y = white_lms.x + white_lms.y; + float3 white_at_y = white_lms * (y / white_y); + float3 delta = lms - white_at_y; + delta *= availability_lms; + + return white_at_y + delta; +} + +float3 ComputeAvailabilityFromAdaptedLMS(float3 adapted_lms, float blend, + float diffuse_white_nits = 100.f, + float pupil_area_mm2 = 10.f, + float half_bleach_trolands = + rushton_henry::CONE_HALF_BLEACH_TROLANDS) { + float3 stimulus_trolands = max(adapted_lms, 0) * diffuse_white_nits * pupil_area_mm2; + float3 availability = rushton_henry::AvailabilityFromTrolands( + stimulus_trolands, half_bleach_trolands); + + return lerp(1.f, availability, blend); +} + + + +} // namespace bleaching +} // namespace color +} // namespace renodx + +#endif // SRC_SHADERS_COLOR_BLEACHING_HLSL_ diff --git a/src/games/deathstranding2/tonemap/psychov25/nrg.hlsli b/src/games/deathstranding2/tonemap/psychov25/nrg.hlsli new file mode 100644 index 000000000..19cd8a1cd --- /dev/null +++ b/src/games/deathstranding2/tonemap/psychov25/nrg.hlsli @@ -0,0 +1,919 @@ +#ifndef RENODX_SHADERS_TONEMAP_NRG_HLSL_ +#define RENODX_SHADERS_TONEMAP_NRG_HLSL_ + +#include "../common.hlsli" +#include "./acc_dkl.hlsli" +#include "./bleaching.hlsli" +#include "./stockman.hlsli" + +namespace renodx { +namespace tonemap { +namespace nrg { + +static const int NRG_BLEACH_MODEL_SCALAR = 0; +static const int NRG_BLEACH_MODEL_PER_CONE = 1; +static const int NRG_TEST5_ENERGY_BT2020_ABS_SUM = 0; +static const int NRG_TEST5_ENERGY_LMS_D65_ABS_SUM = 1; +static const int NRG_TEST5_ENERGY_ACC_A = 2; +static const int NRG_TEST6_CURVE_RH = 0; +static const int NRG_TEST6_CURVE_NR = 1; +// Wider blend to avoid abrupt dark/bright branch flicker around the adaptation anchor. +static const float NRG_TEST6_SIGN_BLEND_WIDTH = 0.08f; +// Test5 target: reach max chroma at 25% RH/Yf-relative progress. +static const float NRG_TEST5_P_WALL = 0.25f; +// CastleCSF uses absolute luminance units (cd/m^2). +// For this test path, scene values are mapped into [min_nits, max_nits] +// where max_nits scales with peak. +static const float NRG_TEST6_CASTLE_MIN_NITS = 0.005f; +static const float NRG_TEST6_CASTLE_BASE_NITS = 100.f; // max_nits when peak == 1 +static const float NRG_TEST6_CASTLE_BACKGROUND_NITS = 5.f; +static const float NRG_TEST6_CASTLE_RHO_CPD = 1.f; +static const float NRG_TEST6_CASTLE_OMEGA_HZ = 0.f; +static const float NRG_TEST6_CASTLE_ECC_DEG = 0.f; +static const float NRG_TEST6_CASTLE_VIS_FIELD_DEG = 0.f; +static const float NRG_TEST6_CASTLE_AREA_DEG2 = 3.14159265f; + +// Anchored Rushton-Henry scalar response for NRGTest4. +// Uses RH availability in relative-drive space, normalized so: +// - y(gray_anchor) = gray_anchor +// - y(infinity) -> peak +float NRGTest4ScalarRushtonHenryToPeak(float x_unit, float peak) { + const float kEps = 1e-6f; + const float kGrayAnchorDefault = 0.18f; + + float p = max(peak, kEps); + float g = min(kGrayAnchorDefault, p * 0.5f); + g = max(g, kEps); + + float relative_drive = max(renodx::math::DivideSafe(max(x_unit, 0.f), g, 0.f), 0.f); + float knee_ratio = max(renodx::math::DivideSafe(p, g, 0.f) - 1.f, kEps); + + float availability = + renodx::color::bleaching::rushton_henry::AvailabilityFromRelativeDrive( + relative_drive, + knee_ratio); + float availability_at_gray = + renodx::color::bleaching::rushton_henry::AvailabilityFromRelativeDrive( + 1.f, + knee_ratio); + + float drive_out = relative_drive * availability; + float drive_out_normalized = + renodx::math::DivideSafe(drive_out, availability_at_gray, 0.f); + + float y = g * drive_out_normalized; + return min(max(y, 0.f), p); +} + +bool IntersectLinearBoundedInterval( + float x0, + float dx, + float min_value, + float max_value, + inout float k_lo, + inout float k_hi) { + const float kSlopeEps = 1e-8f; + if (abs(dx) <= kSlopeEps) { + return x0 >= min_value && x0 <= max_value; + } + + float t0 = renodx::math::DivideSafe(min_value - x0, dx, 0.f); + float t1 = renodx::math::DivideSafe(max_value - x0, dx, 0.f); + float t_min = min(t0, t1); + float t_max = max(t0, t1); + + k_lo = max(k_lo, t_min); + k_hi = min(k_hi, t_max); + return k_hi >= k_lo; +} + +float ComputeAbsSum(float3 v) { + return abs(v.x) + abs(v.y) + abs(v.z); +} + +float NRGTest6PeakWhiteNits(float peak) { + const float kEps = 1e-6f; + float peak_ref = max(peak, kEps); + return max(NRG_TEST6_CASTLE_BASE_NITS * peak_ref, NRG_TEST6_CASTLE_MIN_NITS + kEps); +} + +float3 NRGTest6StimulusNits(float3 bt2020_linear, float peak) { + const float kEps = 1e-6f; + float peak_ref = max(peak, kEps); + float white_nits = NRGTest6PeakWhiteNits(peak_ref); + float3 scene_unit = saturate(bt2020_linear / peak_ref); + return lerp(NRG_TEST6_CASTLE_MIN_NITS.xxx, white_nits.xxx, scene_unit); +} + +float NRGTest6BackgroundYCdM2( + float peak, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { + float white_nits = NRGTest6PeakWhiteNits(peak); + return clamp(background_nits, NRG_TEST6_CASTLE_MIN_NITS, white_nits); +} + +float NRGTest6JNDScalarRaw( + float3 bt2020_linear, + float peak, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { + const float kEps = 1e-6f; + float peak_ref = max(peak, kEps); + float Y0_cd_m2 = NRGTest6BackgroundYCdM2(peak_ref, background_nits); + + // Match CastleCSFOld's relative-drive convention: + // delta is background-relative LMS contrast, then CastleCSF converts to ACC/DKL internally. + float3 stimulus_nits = NRGTest6StimulusNits(bt2020_linear, peak_ref); + float3 lms_stimulus = renodx::color::lms::from::BT2020(stimulus_nits); + float3 xyz_background = renodx::color::xyz::from::xyY(float3(0.31272f, 0.32903f, max(Y0_cd_m2, 1e-4f))); + float3 lms_background = renodx::color::lms::from::XYZ(xyz_background); + float3 delta_lms_relative = (lms_stimulus - lms_background) / max(abs(lms_background), kEps.xxx); + + float4 energy = renodx::color::castlecsf::CastleCSF_Energy( + delta_lms_relative, + max(Y0_cd_m2, 1e-4f), + NRG_TEST6_CASTLE_RHO_CPD, + NRG_TEST6_CASTLE_OMEGA_HZ, + NRG_TEST6_CASTLE_ECC_DEG, + NRG_TEST6_CASTLE_VIS_FIELD_DEG, + NRG_TEST6_CASTLE_AREA_DEG2); + + return max(energy.w, 0.f); +} + +float NRGTest6SignedAchromaticContrast( + float3 bt2020_linear, + float peak = 1.f, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { + const float kEps = 1e-6f; + float peak_ref = max(peak, kEps); + float3 stimulus_nits = NRGTest6StimulusNits(bt2020_linear, peak_ref); + float3 lms_stimulus = renodx::color::lms::from::BT2020(stimulus_nits); + float Y0_cd_m2 = NRGTest6BackgroundYCdM2(peak_ref, background_nits); + float3 xyz_background = renodx::color::xyz::from::xyY(float3(0.31272f, 0.32903f, max(Y0_cd_m2, 1e-4f))); + float3 lms_background = renodx::color::lms::from::XYZ(xyz_background); + + float achromatic_stimulus = lms_stimulus.x + lms_stimulus.y; + float achromatic_background = lms_background.x + lms_background.y; + return renodx::math::DivideSafe( + achromatic_stimulus - achromatic_background, + max(abs(achromatic_background), kEps), + 0.f); +} + +float NRGTest6JNDPeakZeroRaw( + float3 bt2020_linear, + float peak, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { + const float kEps = 1e-6f; + float peak_ref = max(peak, kEps); + float Y0_cd_m2 = NRGTest6BackgroundYCdM2(peak_ref, background_nits); + + float3 lms_stimulus = renodx::color::lms::from::BT2020( + NRGTest6StimulusNits(bt2020_linear, peak_ref)); + float3 lms_black = renodx::color::lms::from::BT2020( + NRGTest6StimulusNits(0, peak_ref)); + float3 lms_background = renodx::color::lms::from::XYZ( + renodx::color::xyz::from::xyY(float3(0.31272f, 0.32903f, max(Y0_cd_m2, 1e-4f)))); + + float3 delta_lms_relative = (lms_stimulus - lms_black) / max(abs(lms_background), kEps.xxx); + float4 energy = renodx::color::castlecsf::CastleCSF_Energy( + delta_lms_relative, + max(Y0_cd_m2, 1e-4f), + NRG_TEST6_CASTLE_RHO_CPD, + NRG_TEST6_CASTLE_OMEGA_HZ, + NRG_TEST6_CASTLE_ECC_DEG, + NRG_TEST6_CASTLE_VIS_FIELD_DEG, + NRG_TEST6_CASTLE_AREA_DEG2); + return max(energy.w, 0.f); +} + +void NRGTest6PerceptualDetailBudgetRaw( + float peak, + float background_nits, + out float detail_budget_dark_raw, + out float detail_budget_bright_raw, + out float detail_budget_max_raw) { + const float kEps = 1e-6f; + float peak_ref = max(peak, kEps); + + // Available perceptual range around the adaptation point: + // - dark side: adaptation -> minimum display luminance + // - bright side: adaptation -> peak white + detail_budget_dark_raw = max( + NRGTest6JNDScalarRaw(0, peak_ref, background_nits), + kEps); + detail_budget_bright_raw = max( + NRGTest6JNDScalarRaw(peak_ref.xxx, peak_ref, background_nits), + kEps); + detail_budget_max_raw = max(detail_budget_dark_raw, detail_budget_bright_raw); +} + +float NRGTest6CurveBudgetUnit( + float budget_unit, + int curve_mode = NRG_TEST6_CURVE_RH) { + float x = saturate(budget_unit); + if (curve_mode == NRG_TEST6_CURVE_NR) { + return saturate(renodx::tonemap::NakaRushton( + x, + 1.f, + 0.18f, + 0.18f, + 1.f)); + } + // Default: feed budget-normalized magnitude into the same RH line used by NRGTest4. + return NRGTest4ScalarRushtonHenryToPeak(x, 1.f); +} + +float NRGTest6CurveBudgetUnitAnchored( + float budget_unit, + int curve_mode = NRG_TEST6_CURVE_RH) { + const float kEps = 1e-6f; + float x = saturate(budget_unit); + float y0 = NRGTest6CurveBudgetUnit(0.f, curve_mode); + float y1 = NRGTest6CurveBudgetUnit(1.f, curve_mode); + float y = NRGTest6CurveBudgetUnit(x, curve_mode); + return saturate(renodx::math::DivideSafe(y - y0, max(y1 - y0, kEps), 0.f)); +} + +float3 SolveLineByJNDScalar( + float3 start_bt2020, + float3 end_bt2020, + float peak, + float target_scalar_raw, + out float scalar_out_raw, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { + const float kEps = 1e-6f; + const int kIterations = 16; + float peak_ref = max(peak, kEps); + + float scalar_start = NRGTest6JNDPeakZeroRaw(start_bt2020, peak_ref, background_nits); + float scalar_end = NRGTest6JNDPeakZeroRaw(end_bt2020, peak_ref, background_nits); + bool increasing = scalar_end >= scalar_start; + + if ((increasing && target_scalar_raw <= scalar_start + kEps) || (!increasing && target_scalar_raw >= scalar_start - kEps)) { + scalar_out_raw = scalar_start; + return start_bt2020; + } + if ((increasing && target_scalar_raw >= scalar_end - kEps) || (!increasing && target_scalar_raw <= scalar_end + kEps)) { + scalar_out_raw = scalar_end; + return end_bt2020; + } + + float lo = 0.f; + float hi = 1.f; + + [unroll] + for (int i = 0; i < kIterations; ++i) { + float mid = 0.5f * (lo + hi); + float3 sample_bt2020 = lerp(start_bt2020, end_bt2020, mid); + float scalar_sample = NRGTest6JNDPeakZeroRaw(sample_bt2020, peak_ref, background_nits); + if ((increasing && scalar_sample < target_scalar_raw) || (!increasing && scalar_sample > target_scalar_raw)) { + lo = mid; + } else { + hi = mid; + } + } + + float t = 0.5f * (lo + hi); + float3 bt2020_out = lerp(start_bt2020, end_bt2020, t); + scalar_out_raw = NRGTest6JNDPeakZeroRaw(bt2020_out, peak_ref, background_nits); + return bt2020_out; +} + +float3 BlendChromaAndWhiteSpillJND( + float3 bt2020_chroma, + float3 bt2020_chroma_max, + float peak, + float scalar_output_raw, + float scalar_chroma_max, + float scalar_white_raw, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { + float scalar_final_raw; + float3 bt2020_spill = SolveLineByJNDScalar( + bt2020_chroma_max, + peak.xxx, + peak, + scalar_output_raw, + scalar_final_raw, + background_nits); + + float wall_width = max(0.02f * scalar_white_raw, 1e-6f); + float wall_mix = smoothstep( + scalar_chroma_max - wall_width, + scalar_chroma_max + wall_width, + scalar_output_raw); + + return lerp(bt2020_chroma, bt2020_spill, wall_mix); +} + +// Find first in-gamut point on the line from bt2020_input toward neutral white (peak,peak,peak): +// p(t) = bt2020_input + t * (white - bt2020_input), t in [0,1] +// We return t_lo (entry point from out-of-gamut side), which is guaranteed to exist +// because t=1 is always white and in gamut. +float SolveBT2020BoundaryTowardWhite( + float3 bt2020_input, + float peak, + out float3 out_bt2020) { + float3 white = peak.xxx; + float3 delta = white - bt2020_input; + + float t_lo = 0.f; + float t_hi = 1.f; + if (!IntersectLinearBoundedInterval(bt2020_input.x, delta.x, 0.f, peak, t_lo, t_hi) || !IntersectLinearBoundedInterval(bt2020_input.y, delta.y, 0.f, peak, t_lo, t_hi) || !IntersectLinearBoundedInterval(bt2020_input.z, delta.z, 0.f, peak, t_lo, t_hi)) { + out_bt2020 = white; + return 1.f; + } + + out_bt2020 = bt2020_input + delta * t_lo; + return t_lo; +} + +float3 ComputeBT2020ChromaMaxFromInput(float3 bt2020_linear, float peak_ref, float kEps) { + float3 bt2020_chroma_max; + + float max_channel = max(max(bt2020_linear.x, bt2020_linear.y), bt2020_linear.z); + bool use_bt2020_hue_boundary = all(bt2020_linear >= 0) && max_channel > kEps; + if (use_bt2020_hue_boundary) { + float3 bt2020_hue_unit = bt2020_linear / max_channel; + bt2020_chroma_max = bt2020_hue_unit * peak_ref; + } else { + // Signed/out-of-gamut input: + // preserve usable hue direction from positive BT.2020 components. + float3 bt2020_positive = max(bt2020_linear, 0); + float positive_max = max(max(bt2020_positive.x, bt2020_positive.y), bt2020_positive.z); + if (positive_max > kEps) { + float3 bt2020_hue_unit = bt2020_positive / positive_max; + bt2020_chroma_max = bt2020_hue_unit * peak_ref; + } else { + SolveBT2020BoundaryTowardWhite( + bt2020_linear, + peak_ref, + bt2020_chroma_max); + } + } + + return bt2020_chroma_max; +} + +// Solve t in out = lerp(bt2020_start, peak_white, t) such that +// abs-sum energy in BT.2020 channel space matches target_scalar_raw. +float3 SolveWhiteSpillByEnergy( + float3 bt2020_start, + float peak, + float target_scalar_raw, + out float scalar_out_raw) { + const float kEps = 1e-6f; + + float scalar_start = ComputeAbsSum(bt2020_start); + + float3 bt2020_white = peak.xxx; + float scalar_white = ComputeAbsSum(bt2020_white); + + if (target_scalar_raw <= scalar_start + kEps) { + scalar_out_raw = scalar_start; + return bt2020_start; + } + if (target_scalar_raw >= scalar_white - kEps) { + scalar_out_raw = scalar_white; + return bt2020_white; + } + + float t = saturate(renodx::math::DivideSafe( + target_scalar_raw - scalar_start, + scalar_white - scalar_start, + 0.f)); + float3 bt2020_out = lerp(bt2020_start, bt2020_white, t); + scalar_out_raw = ComputeAbsSum(bt2020_out); + return bt2020_out; +} + +// Smooth blend across the chroma wall to avoid a visible derivative kink +// at the handoff between \"scale-to-max-chroma\" and \"spill-to-white\". +float3 BlendChromaAndWhiteSpill( + float3 bt2020_chroma, + float3 bt2020_chroma_max, + float peak, + float scalar_output_raw, + float scalar_chroma_max) { + float scalar_final_raw; + float3 bt2020_spill = SolveWhiteSpillByEnergy( + bt2020_chroma_max, + peak, + scalar_output_raw, + scalar_final_raw); + + float scalar_white = 3.f * max(peak, 1e-6f); + float wall_width = max(0.02f * scalar_white, 1e-6f); + float wall_mix = smoothstep( + scalar_chroma_max - wall_width, + scalar_chroma_max + wall_width, + scalar_output_raw); + + return lerp(bt2020_chroma, bt2020_spill, wall_mix); +} + +float3 FastInputLMSEnergyGray(float3 bt709_linear) { + float3 lms = renodx::color::lms::from::BT709(bt709_linear); + float3 lms_white = renodx::color::lms::from::WhiteD65(1.f); + + float3 lms_norm = lms / lms_white; + float scalar_raw = abs(lms_norm.x) + abs(lms_norm.y) + abs(lms_norm.z); + float scalar_input = scalar_raw / 3.f; + return scalar_input.xxx; +} + +float3 NeutwoBT709WhiteForEnergy(float3 bt709_linear, float peak = 1.f) { + const float kEps = 1e-6f; + const float kType7WhiteUnits = 3.f; + const float kChromaCurve = 1.5f; + + float3 lms = renodx::color::lms::from::BT709(bt709_linear); + float3 lms_white = renodx::color::lms::from::WhiteD65(1.f); + + float3 lms_norm_input = lms / lms_white; + float scalar_input_raw = abs(lms_norm_input.x) + abs(lms_norm_input.y) + abs(lms_norm_input.z); + float scalar_input = scalar_input_raw / kType7WhiteUnits; + + float peak_ref = max(peak, kEps); + float scalar_peak = peak_ref; + float scalar_output = renodx::tonemap::Neutwo(scalar_input, scalar_peak); + + float3 lms_d65 = lms / renodx::color::lms::from::WhiteD65(1.f); + float3 acc_input = renodx::color::stockman::acc::from::LMSD65(lms_d65); + float t = saturate(scalar_output / scalar_peak); + float chroma_scale = 1.f - pow(t, kChromaCurve); + float2 acc_chroma_out = acc_input.yz * chroma_scale; + + float3 lms_white_target_d65 = scalar_output.xxx; + float3 acc_white = renodx::color::stockman::acc::from::LMSD65(lms_white_target_d65); + + float3 acc_out = float3(acc_white.x, acc_chroma_out.x, acc_chroma_out.y); + float3 lms_out_d65 = renodx::color::stockman::acc::to::LMSD65(acc_out); + float3 lms_out = lms_out_d65 * renodx::color::lms::from::WhiteD65(1.f); + + float3 lms_norm_scalar = lms_out / lms_white; + float scalar_out_raw = abs(lms_norm_scalar.x) + abs(lms_norm_scalar.y) + abs(lms_norm_scalar.z); + float scalar_target_raw = scalar_output * kType7WhiteUnits; + float scalar_match_scale = scalar_target_raw / max(scalar_out_raw, kEps); + lms_out *= scalar_match_scale; + + return renodx::color::bt709::from::LMS(lms_out); +} + +float3 NRGTest2(float3 bt709_linear, float peak = 1.f) { + const float kEps = 1e-6f; + const float kUnits = 1.f; + const float strength = 0.18f * peak; + float peak_ref = max(peak, kEps); + + float3 lms = renodx::color::lms::from::BT709(bt709_linear); + float3 lms_white = renodx::color::lms::from::WhiteE(1.f); + + float3 lms_norm_input = lms / lms_white; + float scalar_raw_input = lms_norm_input.x + lms_norm_input.y + lms_norm_input.z; + float scalar_input = scalar_raw_input / kUnits; + + float3 lms_peak = lms_white * peak_ref; + float3 lms_norm_peak = lms_peak / lms_white; + float scalar_raw_peak = lms_norm_peak.x + lms_norm_peak.y + lms_norm_peak.z; + float scalar_peak = scalar_raw_peak / kUnits; + float scalar_output = renodx::tonemap::Neutwo(scalar_input, scalar_peak); + + float scalar_input_raw = scalar_input * kUnits; + float scalar_output_raw = scalar_output * kUnits; + + float3 lms_gray = lms_white * strength; + float3 lms_gray_in = lms_gray * scalar_input_raw; + float3 lms_gray_out = lms_gray * scalar_output_raw; + float3 lms_chroma = lms - lms_gray_in; + float available_white = saturate(renodx::math::DivideSafe( + scalar_peak - scalar_output, + scalar_peak, + 0.f)); + + float3 lms_out = lms_gray_out + lms_chroma * available_white; + float3 lms_norm_out = lms_out / lms_white; + float scalar_out_raw = lms_norm_out.x + lms_norm_out.y + lms_norm_out.z; + lms_out *= renodx::math::DivideSafe(scalar_output_raw, scalar_out_raw, 0.f); + + lms_norm_out = lms_out / lms_white; + scalar_out_raw = lms_norm_out.x + lms_norm_out.y + lms_norm_out.z; + lms_out *= renodx::math::DivideSafe(scalar_output_raw, scalar_out_raw, 0.f); + + float3 bt709_out = renodx::color::bt709::from::LMS(lms_out); + float3 bt2020_out = renodx::color::bt2020::from::BT709(bt709_out); + bt2020_out = clamp(bt2020_out, 0.f, peak_ref.xxx); + return renodx::color::bt709::from::BT2020(bt2020_out); +} + +float3 NRGTest3BT2020(float3 bt2020_linear, float peak = 1.f) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; // BT.2020 abs-sum: white@1 = 3, white@peak = 3*peak. + float peak_ref = max(peak, kEps); + + // Scalar units in BT.2020: + // white@1 = 3, peak(8) = 24. + float scalar_input_raw = ComputeAbsSum(bt2020_linear); + float scalar_input_unit = scalar_input_raw / kScalarWhiteUnits; + float scalar_output_unit = renodx::tonemap::NakaRushton( + scalar_input_unit, + peak_ref, + 0.18f, + 0.18f, + 1.f); + float scalar_output_raw = scalar_output_unit * kScalarWhiteUnits; + float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); + + float scalar_chroma_max = ComputeAbsSum(bt2020_chroma_max); + if (scalar_chroma_max <= kEps) { + // Degenerate case: boundary is black. Move on black->white by scalar budget. + float scalar_final_raw; + return SolveWhiteSpillByEnergy( + 0, + peak_ref, + scalar_output_raw, + scalar_final_raw); + } + + // Chroma budget does NOT pass through Neutwo; only E_in does. + float scalar_chroma = min(scalar_output_raw, scalar_chroma_max); + float chroma_scale = renodx::math::DivideSafe( + scalar_chroma, + scalar_chroma_max, + 0.f); + float3 bt2020_chroma = bt2020_chroma_max * chroma_scale; + return BlendChromaAndWhiteSpill( + bt2020_chroma, + bt2020_chroma_max, + peak_ref, + scalar_output_raw, + scalar_chroma_max); +} + +float3 NRGTest3(float3 bt709_linear, float peak = 1.f) { + float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); + float3 bt2020_out = NRGTest3BT2020(bt2020_linear, peak); + return renodx::color::bt709::from::BT2020(bt2020_out); +} + +float3 NRGTest4BT2020(float3 bt2020_linear, float peak = 1.f) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; // BT.2020 abs-sum: white@1 = 3, white@peak = 3*peak. + float peak_ref = max(peak, kEps); + + // Scalar units in BT.2020: + // white@1 = 3, peak(8) = 24. + float scalar_input_raw = ComputeAbsSum(bt2020_linear); + float scalar_input_unit = scalar_input_raw / kScalarWhiteUnits; + float scalar_output_unit = NRGTest4ScalarRushtonHenryToPeak(scalar_input_unit, peak_ref); + float scalar_output_raw = scalar_output_unit * kScalarWhiteUnits; + float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); + + float scalar_chroma_max = ComputeAbsSum(bt2020_chroma_max); + if (scalar_chroma_max <= kEps) { + // Degenerate case: boundary is black. Move on black->white by scalar budget. + float scalar_final_raw; + return SolveWhiteSpillByEnergy( + 0, + peak_ref, + scalar_output_raw, + scalar_final_raw); + } + + // Chroma budget does NOT pass through Rushton-Henry; only E_in does. + float scalar_chroma = min(scalar_output_raw, scalar_chroma_max); + float chroma_scale = renodx::math::DivideSafe( + scalar_chroma, + scalar_chroma_max, + 0.f); + float3 bt2020_chroma = bt2020_chroma_max * chroma_scale; + return BlendChromaAndWhiteSpill( + bt2020_chroma, + bt2020_chroma_max, + peak_ref, + scalar_output_raw, + scalar_chroma_max); +} + +float3 NRGTest4(float3 bt709_linear, float peak = 1.f) { + float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); + float3 bt2020_out = NRGTest4BT2020(bt2020_linear, peak); + return renodx::color::bt709::from::BT2020(bt2020_out); +} + +float NRGTest5ScalarInputUnit( + float3 bt2020_linear, + int energy_mode = NRG_TEST5_ENERGY_ACC_A) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; + + float3 lms_d65 = renodx::color::lms::from::BT2020(bt2020_linear) / max(renodx::color::lms::from::WhiteD65(1.f), 1e-6f.xxx); + if (energy_mode == NRG_TEST5_ENERGY_ACC_A) { + float3 acc = renodx::color::stockman::acc::from::LMSD65(lms_d65); + float acc_white = max(abs(renodx::color::stockman::acc::from::LMSD65(float3(1, 1, 1)).x), kEps); + return abs(acc.x) / acc_white; + } + + if (energy_mode == NRG_TEST5_ENERGY_LMS_D65_ABS_SUM) { + return ComputeAbsSum(lms_d65) / kScalarWhiteUnits; + } + + return ComputeAbsSum(bt2020_linear) / kScalarWhiteUnits; +} + +float NRGTest7ScalarAccARaw( + float3 bt2020_linear, + float peak = 1.f) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; + float peak_ref = max(peak, kEps); + + float scalar_unit = NRGTest5ScalarInputUnit( + max(bt2020_linear, 0), + NRG_TEST5_ENERGY_ACC_A); + return scalar_unit * kScalarWhiteUnits; +} + +float3 NRGTest7SolveWhiteSpillByScalarAccA( + float3 bt2020_start, + float peak, + float target_scalar_raw, + out float scalar_out_raw) { + const float kEps = 1e-6f; + const int kIterations = 16; + const float kScalarWhiteUnits = 3.f; + float peak_ref = max(peak, kEps); + + float3 bt2020_white = peak_ref.xxx; + float scalar_start = NRGTest7ScalarAccARaw(bt2020_start, peak_ref); + float scalar_white = kScalarWhiteUnits * peak_ref; + float scalar_target = clamp(target_scalar_raw, scalar_start, scalar_white); + + if (scalar_target <= scalar_start + kEps) { + scalar_out_raw = scalar_start; + return bt2020_start; + } + if (scalar_target >= scalar_white - kEps) { + scalar_out_raw = scalar_white; + return bt2020_white; + } + + float lo = 0.f; + float hi = 1.f; + [unroll] + for (int i = 0; i < kIterations; ++i) { + float mid = 0.5f * (lo + hi); + float3 sample = lerp(bt2020_start, bt2020_white, mid); + float scalar_mid = NRGTest7ScalarAccARaw(sample, peak_ref); + if (scalar_mid < scalar_target) { + lo = mid; + } else { + hi = mid; + } + } + + float t = 0.5f * (lo + hi); + float3 out_bt2020 = lerp(bt2020_start, bt2020_white, t); + scalar_out_raw = NRGTest7ScalarAccARaw(out_bt2020, peak_ref); + return out_bt2020; +} + +float3 NRGTest7BlendChromaAndWhiteSpillNeutwoClipHueWall( + float3 bt2020_chroma, + float3 bt2020_chroma_max, + float peak, + float scalar_output_raw, + float scalar_chroma_max, + float start_ratio = 1.f, + float shape = 1.f) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; + float peak_ref = max(peak, kEps); + float scalar_white_raw = kScalarWhiteUnits * peak_ref; + + float scalar_start = scalar_chroma_max * saturate(start_ratio); + float scalar_overdrive = max(scalar_output_raw - scalar_start, 0.f); + float scalar_headroom = max(scalar_white_raw - scalar_start, kEps); + float scalar_overdrive_unit = scalar_overdrive / scalar_headroom; + + // Per-hue clip from wall capacity in ACC-A scalar units. + // low wall -> clip near 1 (faster white), high wall -> clip near 2 (slower white) + float clip_hue = 1.f + saturate(renodx::math::DivideSafe(scalar_chroma_max, max(scalar_white_raw, kEps), 0.f)); + + float white_mix = saturate(renodx::tonemap::Neutwo( + scalar_overdrive_unit, + 1.f, + clip_hue)); + if (abs(shape - 1.f) > 1e-6f) { + white_mix = pow(max(white_mix, 0.f), max(shape, 1e-6f)); + } + + float scalar_spill_raw; + float3 bt2020_spill = NRGTest7SolveWhiteSpillByScalarAccA( + bt2020_chroma_max, + peak_ref, + scalar_output_raw, + scalar_spill_raw); + return lerp(bt2020_chroma, bt2020_spill, white_mix); +} + +float3 NRGTest5BT2020( + float3 bt2020_linear, + float peak = 1.f, + int energy_mode = NRG_TEST5_ENERGY_ACC_A) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; // BT.2020 abs-sum: white@1 = 3, white@peak = 3*peak. + float peak_ref = max(peak, kEps); + + // Test5 keeps Test4's robust hue geometry, but allows alternate scalar energy drives. + float scalar_input_unit = NRGTest5ScalarInputUnit(bt2020_linear, energy_mode); + float scalar_output_unit = NRGTest4ScalarRushtonHenryToPeak(scalar_input_unit, peak_ref); + float scalar_output_raw = scalar_output_unit * kScalarWhiteUnits; + float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); + + float scalar_chroma_max = ComputeAbsSum(bt2020_chroma_max); + if (scalar_chroma_max <= kEps) { + // Degenerate hue: move on neutral axis by RH scalar percent. + return (scalar_output_raw / kScalarWhiteUnits).xxx; + } + + float scalar_white_raw = kScalarWhiteUnits * peak_ref; + float p = saturate(renodx::math::DivideSafe( + scalar_output_raw, + scalar_white_raw, + 0.f)); + + float p_wall = clamp(NRG_TEST5_P_WALL, 1e-4f, 0.9999f); + if (p <= p_wall) { + // Stage 1: black -> max chroma (at p_wall). + float chroma_t = saturate(renodx::math::DivideSafe(p, p_wall, 0.f)); + return bt2020_chroma_max * chroma_t; + } + + // Stage 2: max chroma -> white. Max chroma is only present at p == p_wall. + float white_t = saturate(renodx::math::DivideSafe( + p - p_wall, + 1.f - p_wall, + 0.f)); + return lerp(bt2020_chroma_max, peak_ref.xxx, white_t); +} + +float3 NRGTest5( + float3 bt709_linear, + float peak = 1.f, + int energy_mode = NRG_TEST5_ENERGY_ACC_A) { + float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); + float3 bt2020_out = NRGTest5BT2020(bt2020_linear, peak, energy_mode); + return renodx::color::bt709::from::BT2020(bt2020_out); +} + +float3 NRGTest6BT2020( + float3 bt2020_linear, + float peak = 1.f, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS, + int curve_mode = NRG_TEST6_CURVE_RH) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; // Virtual spill pressure only; final targets stay <= white JND. + float peak_ref = max(peak, kEps); + // Test4 geometry, but scalar is total JND from black->color normalized by black->peak. + float scalar_peak_raw = max( + NRGTest6JNDPeakZeroRaw(peak_ref.xxx, peak_ref, background_nits), + kEps); + float scalar_input_raw = NRGTest6JNDPeakZeroRaw(bt2020_linear, peak_ref, background_nits); + float scalar_input_unit = scalar_input_raw * peak_ref / scalar_peak_raw; + float scalar_output_unit = curve_mode == NRG_TEST6_CURVE_NR + ? renodx::tonemap::NakaRushton(scalar_input_unit, peak_ref, 0.18f, 0.18f, 1.f) + : NRGTest4ScalarRushtonHenryToPeak(scalar_input_unit, peak_ref); + float scalar_output_raw = scalar_output_unit * scalar_peak_raw / peak_ref; + float scalar_white_raw = scalar_peak_raw; + + float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); + float scalar_chroma_max = NRGTest6JNDPeakZeroRaw(bt2020_chroma_max, peak_ref, background_nits); + if (scalar_chroma_max <= kEps) { + float scalar_final_raw; + return SolveLineByJNDScalar( + 0, + peak_ref.xxx, + peak_ref, + scalar_output_raw, + scalar_final_raw, + background_nits); + } + + // Apply extra spill pressure in a virtual scalar domain, but remap the result + // back into the physically reachable JND interval [scalar_chroma_max, scalar_peak_raw]. + float scalar_headroom = max(scalar_peak_raw - scalar_chroma_max, 0.f); + if (scalar_headroom > kEps) { + float scalar_output_virtual = scalar_output_raw * kScalarWhiteUnits; + float scalar_overflow = max(scalar_output_virtual - scalar_chroma_max, 0.f); + if (scalar_overflow > kEps) { + float scalar_overflow_norm = saturate(renodx::math::DivideSafe( + scalar_overflow, + max(scalar_peak_raw * (kScalarWhiteUnits - 1.f), kEps), + 0.f)); + float scalar_spill_target = lerp(scalar_chroma_max, scalar_peak_raw, scalar_overflow_norm); + scalar_output_raw = max(scalar_output_raw, scalar_spill_target); + } + } + + float scalar_chroma = min(scalar_output_raw, scalar_chroma_max); + float scalar_chroma_raw; + float3 bt2020_chroma = SolveLineByJNDScalar( + 0, + bt2020_chroma_max, + peak_ref, + scalar_chroma, + scalar_chroma_raw, + background_nits); + + return BlendChromaAndWhiteSpillJND( + bt2020_chroma, + bt2020_chroma_max, + peak_ref, + scalar_output_raw, + scalar_chroma_max, + scalar_white_raw, + background_nits); +} + +float3 NRGTest6( + float3 bt709_linear, + float peak = 1.f, + float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS, + int curve_mode = NRG_TEST6_CURVE_RH) { + float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); + float3 bt2020_out = NRGTest6BT2020(bt2020_linear, peak, background_nits, curve_mode); + return renodx::color::bt709::from::BT2020(bt2020_out); +} + +float3 NRGTest7HueClipBT2020(float3 bt2020_linear, float peak = 1.f) { + const float kEps = 1e-6f; + const float kScalarWhiteUnits = 3.f; + float peak_ref = max(peak, kEps); + + // ACC-A scalar drive -> Neutwo white curve. + float scalar_input_unit = NRGTest5ScalarInputUnit( + max(bt2020_linear, 0), + NRG_TEST5_ENERGY_ACC_A); + float scalar_output_unit = renodx::tonemap::Neutwo( + max(scalar_input_unit, 0.f), + peak_ref); + float scalar_output_raw = scalar_output_unit * kScalarWhiteUnits; + + // Max-hue anchor in BT.2020, then transition toward white. + float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); + float scalar_chroma_max = NRGTest7ScalarAccARaw(bt2020_chroma_max, peak_ref); + if (scalar_chroma_max <= kEps) { + float scalar_final_raw; + return NRGTest7SolveWhiteSpillByScalarAccA( + 0, + peak_ref, + scalar_output_raw, + scalar_final_raw); + } + + float scalar_chroma = min(scalar_output_raw, scalar_chroma_max); + float chroma_scale = renodx::math::DivideSafe( + scalar_chroma, + scalar_chroma_max, + 0.f); + float3 bt2020_chroma = bt2020_chroma_max * chroma_scale; + + return NRGTest7BlendChromaAndWhiteSpillNeutwoClipHueWall( + bt2020_chroma, + bt2020_chroma_max, + peak_ref, + scalar_output_raw, + scalar_chroma_max, + 1.f, + 1.f); +} + +float3 NRGTest7HueClip(float3 bt709_linear, float peak = 1.f) { + float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); + float3 bt2020_out = NRGTest7HueClipBT2020(bt2020_linear, peak); + return renodx::color::bt709::from::BT2020(bt2020_out); +} + +float3 BT709TEST7(float3 bt709_linear, + float display_peak = 1.f, + int mode = NRG_BLEACH_MODEL_SCALAR) { + if (mode == NRG_BLEACH_MODEL_PER_CONE) { + return NeutwoBT709WhiteForEnergy(bt709_linear, display_peak); + } + return FastInputLMSEnergyGray(bt709_linear); +} + +float3 BT2020TEST7(float3 bt2020_linear, + float display_peak_bt2020 = 1.f, + int mode = NRG_BLEACH_MODEL_SCALAR) { + float3 bt709 = renodx::color::bt709::from::BT2020(bt2020_linear); + float3 out_bt709 = BT709TEST7(bt709, display_peak_bt2020, mode); + return renodx::color::bt2020::from::BT709(out_bt709); +} + +} // namespace nrg +} // namespace tonemap +} // namespace renodx + +#endif // RENODX_SHADERS_TONEMAP_NRG_HLSL_ diff --git a/src/games/deathstranding2/tonemap/psychov25/stockman.hlsli b/src/games/deathstranding2/tonemap/psychov25/stockman.hlsli new file mode 100644 index 000000000..b9d2b3a16 --- /dev/null +++ b/src/games/deathstranding2/tonemap/psychov25/stockman.hlsli @@ -0,0 +1,112 @@ +#ifndef SRC_SHADERS_COLOR_STOCKMAN_HLSL_ +#define SRC_SHADERS_COLOR_STOCKMAN_HLSL_ + +#include "../common.hlsli" + +// Deprecated (use renodx::color::lms::* directly) + +namespace renodx { +namespace color { +namespace bt709 { +namespace from { + +float3 StockmanDKL(float3 dkl) { + // Modified Stockman & Sharpe for LCD LED + float3x3 XYZ_TO_LMS_WUERGER_2020 = float3x3( + 0.187596268556126, 0.585168649077728, -0.026384263306304, + -0.133397430663221, 0.405505777260049, 0.034502127690364, + 0.000244379021663, -0.000542995890619, 0.019406849066323); + + // Manually recomputed from CIE 1931 XYZ 1nm to Stockman 2deg 1nm 8dp with MB2 Weights + float3x3 XYZ_TO_LMS_2006 = float3x3( + 0.185082982238733f, 0.584081279463687f, -0.0240722415044404f, + -0.134433056469973f, 0.405752392775348f, 0.0358252602217631f, + 0.000789456671966863f, -0.000912281325916184f, 0.0198490812339463f); + + float3x3 XYZ_FROM_LMS = renodx::math::Invert3x3(XYZ_TO_LMS_2006); + + // CIE 1931 2 degree standard observer + float2 WHITE_POINT_D65 = float2(0.31272, 0.32903); + float3 D65_XYZ = renodx::color::xyz::from::xyY(float3(WHITE_POINT_D65, 1.f)); + float3 LMS_WHITE = mul(XYZ_TO_LMS_2006, D65_XYZ); + + float mc1 = LMS_WHITE.x / LMS_WHITE.y; + float mc2 = (LMS_WHITE.x + LMS_WHITE.y) / LMS_WHITE.z; + + // actual ACC color space (DKL-like / ACC) + float3x3 LMS_TO_DKL_D65 = float3x3( + 1, 1, 0, + 1, -mc1, 0, + -1, -1, mc2); + + float3x3 LMS_FROM_DKL_D65 = renodx::math::Invert3x3(LMS_TO_DKL_D65); + + float3x3 RGB_TO_DKL_D65 = mul(LMS_TO_DKL_D65, XYZ_TO_LMS_2006); + float3x3 DKL_D65_TO_RGB = renodx::math::Invert3x3(RGB_TO_DKL_D65); + + float3 lms_color = mul(LMS_FROM_DKL_D65, dkl); + + float3 lms_background = mul(XYZ_TO_LMS_2006, renodx::color::xyz::from::xyY(float3(WHITE_POINT_D65, 1.00f))); + + lms_background = 0; // skip for now + float3 lms_final = lms_color + lms_background; + + float3 xyz = mul(XYZ_FROM_LMS, lms_final); + + float3 bt709 = renodx::color::bt709::from::XYZ(xyz); + return bt709; +} +} // namespace from +} // namespace bt709 + +namespace stockmandkl { +namespace from { +float3 BT709(float3 bt709) { + // Modified Stockman & Sharpe for LCD LED + float3x3 XYZ_TO_LMS_WUERGER_2020 = float3x3( + 0.187596268556126, 0.585168649077728, -0.026384263306304, + -0.133397430663221, 0.405505777260049, 0.034502127690364, + 0.000244379021663, -0.000542995890619, 0.019406849066323); + + // Manually recomputed from CIE 1931 XYZ 1nm to Stockman 2deg 1nm 8dp with MB2 Weights + float3x3 XYZ_TO_LMS_2006 = float3x3( + 0.185082982238733f, 0.584081279463687f, -0.0240722415044404f, + -0.134433056469973f, 0.405752392775348f, 0.0358252602217631f, + 0.000789456671966863f, -0.000912281325916184f, 0.0198490812339463f); + + float3x3 XYZ_FROM_LMS = renodx::math::Invert3x3(XYZ_TO_LMS_2006); + + // CIE 1931 2 degree standard observer + float2 WHITE_POINT_D65 = float2(0.31272, 0.32903); + float3 D65_XYZ = renodx::color::xyz::from::xyY(float3(WHITE_POINT_D65, 1.f)); + float3 LMS_WHITE = mul(XYZ_TO_LMS_2006, D65_XYZ); + + float mc1 = LMS_WHITE.x / LMS_WHITE.y; + float mc2 = (LMS_WHITE.x + LMS_WHITE.y) / LMS_WHITE.z; + + // actual ACC color space (DKL-like / ACC) + float3x3 LMS_TO_DKL_D65 = float3x3( + 1, 1, 0, + 1, -mc1, 0, + -1, -1, mc2); + + float3x3 LMS_FROM_DKL_D65 = renodx::math::Invert3x3(LMS_TO_DKL_D65); + float3 xyz = renodx::color::xyz::from::BT709(bt709); + float3 lms_input = mul(XYZ_TO_LMS_2006, xyz); + float3 dkl_input = mul(LMS_TO_DKL_D65, lms_input); + + float3 lms_background = mul(XYZ_TO_LMS_2006, renodx::color::xyz::from::xyY(float3(WHITE_POINT_D65, 1.00f))); + + lms_background = 0; // skip for now + float3 delta = lms_input - lms_background; + + float3 dkl = mul(LMS_TO_DKL_D65, delta); + + return dkl; +} +} // namespace from +} // namespace stockmandkl + +} // namespace color +} // namespace renodx +#endif // SRC_SHADERS_COLOR_STOCKMAN_HLSL_ \ No newline at end of file diff --git a/src/games/deathstranding2/tonemap/psychov25/test25.hlsli b/src/games/deathstranding2/tonemap/psychov25/test25.hlsli new file mode 100644 index 000000000..1bfed6945 --- /dev/null +++ b/src/games/deathstranding2/tonemap/psychov25/test25.hlsli @@ -0,0 +1,4085 @@ +#ifndef RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ +#define RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ + +#include "../common.hlsli" +#include "./nrg.hlsli" + +/* + * Copyright (C) 2026 Carlos Lopez + * SPDX-License-Identifier: MIT + */ + +namespace renodx { +namespace tonemap { +namespace psychov { + +// Psycho25 current implementation +// ------------------------------- +// 1. Test24 grading and adaptive-MB purity are retained. +// 2. Anchor-matched per-cone contrast uses sign-preserving powers, retaining +// signed cone ratios through authored hue and device-hull work. The +// compression-derived power encodes a cone-response state whose adapted +// origin is exactly one. Encoded-response power acts before the rational +// shoulder. +// 3. Per-channel compression defines the raw adaptive-MB hue shift. +// 4. Graph hue authoring uses the numerical sextant peak-search and local +// graph-inversion solve at 50% amplitude. Fast60 is the lower-cost direct +// angular midpoint between the source and raw per-channel-compressed +// adaptive-MB directions. Cone-axis pins remain zeros because the raw +// per-channel hue shift itself is zero on those axes. +// 5. The actual-peak compressed adaptive-MB radius and carried achromatic +// scale are retained while only the direction is changed. +// 6. With gamut compression disabled, the retained per-cone LMS rolloff is the +// output shoulder and supplies the compression path toward adapted white. +// 7. With either target-plane class enabled, per-cone output compression is +// bypassed. +// The actual-peak per-cone result supplies adaptive-MB magnitude and radius. +// A separate compressed direction, whose neutral endpoint is scaled by +// `guidance_peak_scale`, supplies the hue trajectory guide. +// Normalization discards carried scale while preserving the physical radius +// and guided direction without applying either per-cone curve as the final +// output compressor. +// 8. The trajectory-guided adaptive-MB direction supplies one device-hull +// ray. Primary enforcement uses the selected target RGB lower planes; peak +// enforcement uses its upper planes. The two plane classes are independent. +// With peak enforcement disabled, output follows the authored scalar Yf +// after any requested primary correction. Linear BT.709 return values may +// be negative when they represent valid colors inside a wider selected +// target. +// 9. Selected-target lower-plane feasibility is solved against a same-hue +// reference radius no smaller than the current physical trajectory or its +// uncompressed post-contrast source. A C1 radial shoulder begins at 90% of +// the selected-target lower-plane boundary instead of activating only after +// a channel becomes negative. Reusing that scale over the outer trajectory +// preserves its inward-to-white gradient instead of projecting every +// outside point onto the same gamut boundary. The scale releases smoothly +// toward the physical path near neutral so blue can keep gaining channel +// value without turning gray, with a smooth current-trajectory containment +// cap for signed inputs. This is a direction constraint, not a second +// output compression curve. +// 10. Reference and Reduced Max-White smoothly turn the authored direction +// back toward the pre-contrast source direction as the physical radius +// collapses. They do not retain a nonzero radius: chromatic highlights can +// become lighter and converge on white without first rotating through an +// unrelated hue. +// 11. Reference2 is an experimental Graph-authoritative variant of Reference. +// It retains the six-section direction without the post-Graph source- +// direction recovery or Reference's same-Yf radial contraction. After the +// scalar upper-plane shoulder, lower-plane pressure lifts the complete +// selected-target RGB result smoothly toward peak D65 white. Quadratic +// pressure moves an outside trajectory progressively inward instead of +// flattening it onto a target wall without creating an aggressive Yf hump +// at first contact. The result is then reprojected onto the Graph- +// authored adaptive-MB hue while retaining its raised Yf and reduced radius. +// The physical per-cone radius still converges to peak D65 white. +// 12. Linear MB Pullback is a diagnostic lower-plane mode. It retains the +// Graph/Fast60-authored adaptive-MB direction and actual-peak radius while +// feasible, then linearly reduces only that radius to the first selected- +// target lower-plane intersection. It has no custom reference radius, +// shoulder, neutral release, or source-direction recovery. +// 13. Target RGB Clip is a literal comparison path. It runs the same physical +// per-cone and authored-hue result without target-hull mapping, transforms +// it to the selected linear BT.709 or BT.2020 RGB space, and clamps each +// component directly to [0, peak]. It adds no sectional gamut curve. +// 14. Experimental post-compression is independent of hull selection. Modes +// 1-8 branch from the common post-contrast LMS state before the physical +// per-cone shoulder, Graph/Fast60 hue authoring, or target-hull solve. +// Direct target-RGB per-channel and max-channel shoulders can be compared +// with adaptive-MB hard pullback, adaptive soft compression, RenoDX fixed- +// D65 soft compression, and source-MB-direction variants. Source BT.709 +// Residual retains the default coupled path and replaces only its final +// linear-BT.709 residual direction. PsychoV17 Gamut instead retains +// Test25's physical per-cone shoulder and authored hue, bypasses Test25's +// coupled target-hull solve, then applies PsychoV17's final adaptive- +// relative weighted-LMS target-primary compression. PsychoV17 Gamut + +// Neutwo Max retains that physical/hue trajectory for target-RGB direction, +// derives magnitude from the unbounded post-contrast signal after the same +// gamut map, then uses one anchor-normalized max-channel Neutwo peak map. +// PsychoV17 +// Gamut + NRG White instead retains the completed output's ACC-A scalar +// metric while moving an over-peak selected-target RGB result from its hue +// wall toward peak D65 white. None of these options changes the default +// coupled path. These remain comparison probes rather than candidate +// device-volume mappings: common-scale max-channel modes can terminate on +// a colored wall. +// 15. Sectional White Volume is an experimental coupled-hull alternative. It +// retains the physical per-cone Yf and Graph/Fast60 six-section direction, +// measures their selected-target RGB displacement from the same-Yf D65 +// axis, and applies one globally smooth L8 cube-occupancy response. Lower +// primary and upper peak planes participate in the same cross-sectional +// solve. There is no separate hue-wall handoff, white-spill pass, or final +// component clamp. The inherited physical per-cone endpoint still requires +// every positive hue trajectory to converge to peak D65 white. +// 16. Reference3 is an experimental target-hue-triangle volume map. The +// selected linear RGB cube is decomposed exactly into one triangle per hue: +// black, the max/min target-channel hue-rim point, and peak D65 white. +// Physical per-cone Yf and Graph/Fast60 direction supply the preferred +// point before legacy lower/upper hull passes. Smooth positive barycentric +// weights place an outside point inside its exact target triangle, while +// quadratic lower/upper pressure moves increasingly invalid points toward +// white. Active cube-edge changes come only from target geometry; there is +// no authored hue- or level-segment handoff. +// +// 17. Canonical Cylinder is an experimental star-volume map. It normalizes +// authored adaptive-MB radius by the exact selected-target six-plane radial +// support at each hue/Yf, making every target a unit q-cylinder. Outside +// occupancy is passed through a pivot/contrast/generalized-Neutwo pressure +// response and split between inward q contraction and upward motion toward +// peak D65 white. The target support is re-evaluated at the raised Yf before +// reconstructing the final radius. In-gamut q <= 1 points are exact identity. +// 18. Adaptive Contrast Fit is an experimental post-ideal lost-contrast fit. +// It first completes Test25's ordinary physical/MIDPOINT result, then fits +// that point to the exact selected-target six-plane adaptive-MB radial +// support at the same physical Yf. Lost adaptive-MB radius contributes only +// above the adapted Yf, while genuine lost achromatic Yf contributes +// separately. Their bounded pressure advances one later state on Test25's +// own per-cone/MIDPOINT trajectory, then reapplies the exact target fit. No +// straight target-RGB interpolation to white is used. +// +// Device-hull implementation: +// Peak and RGB-gamut constraints are one device-hull problem. For normalized +// BT.709 output, the complete target is the cube 0 <= R,G,B <= 1, not a +// per-channel move toward white followed by an unrelated gamut constraint. +// With both plane classes enabled, the gamut-active branch evaluates this full +// cube along the numerically solved adaptive-MB trajectory. White is one +// possible intermediate in-hull result, but peak D65 white is the required +// endpoint of every positive hue trajectory. A hue may travel along cube faces +// while clipping, but it must not terminate on a colored face. The restored +// source record and longer-term hull plan +// below distinguish this ray solve from a future sectional optimization over +// multiple candidate points. +// In wide-target mode, the result remains represented as linear BT.709 until +// the caller converts it for output. Negative BT.709 components are therefore +// valid when the represented color is inside the selected wider target. + +static const float PSYCHO25_EPSILON = 1e-6f; +static const float PSYCHO25_PI = 3.14159265358979323846f; +static const float PSYCHO25_TWO_PI = 6.2831853071795864769f; +static const float PSYCHO25_LARGE = 1e20f; +static const float PSYCHO25_MAX_FINITE_INPUT = 65504.f; +static const float PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE = 0.9f; +static const float PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION = 0.75f; +static const float PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON = 1e-5f; +static const float PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER = 256.f; +static const float PSYCHO25_SECTIONAL_VOLUME_POWER = 8.f; +static const float PSYCHO25_WHITE_LIFT_PRESSURE_EPSILON = 1e-7f; + +// Auto-compression reference. +// Simultaneous luminance dynamic range is stimulus- and method-dependent. +// Published values considered for the automatic compression reference: +// - Kunkel & Reinhard, APGV 2010, doi:10.1145/1836248.1836251: +// ~3.7 log10 units under their adapted test conditions. +// - Jiang & Fairchild, JIST 2021, +// doi:10.2352/J.ImagingSci.Technol.2021.65.5.050401: +// direct bright/dark simultaneous measurements on an Apple Pro Display +// XDR setup reported ~3.3 log10 units for the average observer and +// 3.47 log10 units for OBS1 at 1600 cd/m^2, 3.4 degree stimulus size. +// Their spatial-frequency fit reports DRmax values of 3.24 log10 at +// 452 cd/m^2 and 3.40 log10 at 1600 cd/m^2. The display apparatus used +// diffuse white = 50 cd/m^2 and peak luminance = 1600 cd/m^2. +// +// Default choice: +// Kunkel/Reinhard's 3.7 value is the conservative reference. A larger +// reference range increases auto h on low-headroom displays, reducing the +// symmetric curve's OFF/shadow-side bending. Jiang/Fairchild's average is a +// possible direct-display, glare-inclusive alternative. +// +// Model choice: +// For a neutral static curve, the adapted/background state is treated as the +// log midpoint of the selected total range. Half of the log range is above +// adaptation and half below. This is a neutral log-domain prior, not a claim +// that biological ON/OFF pathways are exactly symmetric. +// +// For the slope-normalized compression below, the deep OFF-side slope ratio is: +// S_shadow / contrast = 1 / (1 - pow(anchor_out / peak, h)) +// Auto compression solves: +// h = (reference_range_log10 / 2) / log10(peak / anchor_out) +// which is equivalent to choosing: +// pow(anchor_out / peak, h) = pow(10, -(reference_range_log10 / 2)) +// The implied OFF-side slope ratio is therefore derived from the selected +// reference range rather than from an independent decimal tolerance. +static const float PSYCHO25_REFERENCE_SIMULTANEOUS_RANGE_LOG10 = 3.7f; +static const float PSYCHO25_REFERENCE_CENTERED_RANGE_SIDE_COUNT = 2.f; +// Target-relative neutral Yf endpoint for target-plane hue guidance. Scale 1 +// exactly matches the regular physical per-channel endpoint. +static const float PSYCHO25_MIN_GUIDANCE_PEAK_SCALE = 1.f; +static const float PSYCHO25_DEFAULT_GUIDANCE_PEAK_SCALE = 1.f; +static const float PSYCHO25_MIN_AUTO_COMPRESSION = 1.f; +static const float PSYCHO25_MIN_MANUAL_COMPRESSION = 1e-6f; +static const float PSYCHO25_AUTO_COMPRESSION_SENTINEL = 0.f; +static const float PSYCHO25_UPPER_PLANE_SHOULDER_POWER_MATCH_COMPRESSION = 0.f; + +// RenoDX v4 grading masks are applied to scalar Yf rather than independently +// to L, M, and S. This keeps the adapted anchor fixed and prevents the +// highlight/shadow controls from rotating adaptive-MB hue. +static const float PSYCHO25_HIGHLIGHT_GRADE_REFERENCE_WHITE = 1.f; +static const float PSYCHO25_SHADOW_GRADE_RANGE_STOPS = 4.f; + +// Numerical Graph searches each cone-axis-bounded interval and inverts the +// transformed hue field. Fast60 bypasses these constants and searches. +static const uint PSYCHO25_HUE_PEAK_SCAN_INTERVALS = 6u; +static const uint PSYCHO25_HUE_PEAK_REFINE_ITERATIONS = 12u; +static const uint PSYCHO25_HUE_INVERSE_BRACKET_INTERVALS = 16u; +static const uint PSYCHO25_HUE_INVERSE_ITERATIONS = 18u; +static const float PSYCHO25_HUE_REVERSAL_AXIS_SLOPE_LIMIT = -6.f; +static const float PSYCHO25_HUE_ORDER_DERIVATIVE_PROBE_DIVISOR = 64.f; +static const float PSYCHO25_HUE_ORDER_SAFETY = 0.9f; + +static const float PSYCHO25_HUE_AMPLITUDE = 0.5f; +static const int PSYCHO25_HUE_METHOD_GRAPH = 0; +static const int PSYCHO25_HUE_METHOD_FAST_60 = 1; +static const int PSYCHO25_HULL_METHOD_REFERENCE_SCALE = 0; +static const int PSYCHO25_HULL_METHOD_REDUCED_MAX_WHITE = 1; +static const int PSYCHO25_HULL_METHOD_LINEAR_MB_PULLBACK = 2; +static const int PSYCHO25_HULL_METHOD_TARGET_RGB_CLIP = 3; +static const int PSYCHO25_HULL_METHOD_SECTIONAL_WHITE_VOLUME = 4; +static const int PSYCHO25_HULL_METHOD_REFERENCE2 = 5; +static const int PSYCHO25_HULL_METHOD_REFERENCE3 = 6; +static const int PSYCHO25_HULL_METHOD_CANONICAL_CYLINDER = 7; +static const int PSYCHO25_HULL_METHOD_CANONICAL_YF_CONE = 8; +// Canonical-cylinder experimental defaults. The target RGB cube is reduced to +// q = rho / rho_max(theta, Yf); outside pressure is then redirected both +// inward in q and upward toward peak D65 white before converting back through +// the exact target radial support at the raised Yf. +static const float PSYCHO25_CANONICAL_CYLINDER_DEFAULT_PIVOT = 0.45f; +static const float PSYCHO25_CANONICAL_CYLINDER_DEFAULT_CONTRAST = 1.4f; +static const float PSYCHO25_CANONICAL_CYLINDER_DEFAULT_H = 2.f; +static const float PSYCHO25_CANONICAL_CYLINDER_DEFAULT_TRADE = 0.5f; +// Yf-cone variant: exponent controlling how quickly gamut pressure is allowed +// to become whiteward/achromatic motion. k=2 gives 1% whiteward pressure at +// 10% of target peak, 25% at 50%, and 81% at 90%. +static const float PSYCHO25_CANONICAL_YF_CONE_DEFAULT_BIAS_POWER = 2.f; +static const int PSYCHO25_POST_COMPRESSION_NONE = 0; +static const int PSYCHO25_POST_COMPRESSION_DIRECT = 1; +static const int PSYCHO25_POST_COMPRESSION_PER_CHANNEL = 2; +static const int PSYCHO25_POST_COMPRESSION_MAX_CHANNEL = 3; +static const int PSYCHO25_POST_COMPRESSION_ADAPTIVE_MB_HARD_MAX = 4; +static const int PSYCHO25_POST_COMPRESSION_ADAPTIVE_MB_SOFT_MAX = 5; +static const int PSYCHO25_POST_COMPRESSION_FIXED_D65_SOFT_MAX = 6; +static const int PSYCHO25_POST_COMPRESSION_SOURCE_MB_PER_CHANNEL = 7; +static const int PSYCHO25_POST_COMPRESSION_SOURCE_MB_SOFT_MAX = 8; +static const int PSYCHO25_POST_COMPRESSION_SOURCE_BT709_RESIDUAL = 9; +static const int PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT = 10; +static const int PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NEUTWO_MAX = 11; +static const int PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NRG_WHITE = 12; +static const int PSYCHO25_POST_COMPRESSION_ADAPTIVE_CONTRAST_FIT = 13; +static const int PSYCHO25_UPPER_HULL_PIVOT_BLACK = 0; +static const int PSYCHO25_UPPER_HULL_PIVOT_ADAPTED_OUTPUT = 1; +static const float PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY = 0.8f; +static const float PSYCHO25_REDUCED_MAX_WHITE_SOURCE_DIRECTION_OCCUPANCY = 1.f; +static const float PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION = 0.25f; +static const float PSYCHO25_SOURCE_DIRECTION_BLEND_POWER = 2.f; +static const int PSYCHO25_GAMUT_ENFORCEMENT_NONE = 0; +static const int PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES = 1; +static const int PSYCHO25_GAMUT_ENFORCEMENT_PEAK = 2; +static const int PSYCHO25_GAMUT_ENFORCEMENT_FULL = + PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES + | PSYCHO25_GAMUT_ENFORCEMENT_PEAK; +static const int PSYCHO25_GAMUT_ENFORCEMENT_LEGACY = -1; +static const int PSYCHO25_INPUT_PRESTEP_NONE = 0; +static const int PSYCHO25_INPUT_PRESTEP_POSITIVE_LMS = 1; +static const int PSYCHO25_INPUT_PRESTEP_CIE1702 = 2; +static const int PSYCHO25_INPUT_PRESTEP_CIE1702_ABSOLUTE_YF = 3; +static const int PSYCHO25_OBSERVER_GAMUT_NONE = 0; +static const int PSYCHO25_OBSERVER_GAMUT_CIE1702 = 1; + +struct Psycho25HueSection { + float start; + float end; + float midpoint; + float source_unwrapped; + uint index; +}; + +struct Psycho25HueGeometry { + float peak_angle; + float peak_shift; + float axis_slope; + float maximum_ordered_amplitude; + uint active; +}; + +struct Psycho25ConeResponseState { + float3 encoded_response; + float3 compression_exponent; + float3 input_response_exponent; + float3 encoded_peak_offset; +}; + +struct Psycho25ConeResponseParameters { + float3 anchor_out; + float3 compression_exponent; + float3 input_response_exponent; + float3 encoded_peak_offset; + float encoded_response_power; + float inverse_compression_power; +}; + +struct Psycho25HueEvaluationContext { + Psycho25ConeResponseParameters guidance_cone_response; + float3 current_adaptive_state_lms; + float3 anchor_in; + float3 anchor_out; + float3 guidance_lms_peak; + float2 adapted_neutral_mb; + float source_radius; + float source_target_yf; + float contrast_power; + int observer_gamut_mode; +}; + +struct Psycho25AdaptiveMBTrajectory { + float3 authored_mb; + uint hue_applied; +}; + +float psycho25_Cross2(float2 a, float2 b) { + return a.x * b.y - a.y * b.x; +} + +float psycho25_PositiveHueAngle(float angle) { + angle -= PSYCHO25_TWO_PI * floor(angle / PSYCHO25_TWO_PI); + return angle < 0.f ? angle + PSYCHO25_TWO_PI : angle; +} + +float psycho25_SignedYfFromLMS(float3 lms) { + float3 weighted_lms = + renodx::color::macleod_boynton::WeighLMS(lms); + return weighted_lms.x + weighted_lms.y; +} + +float psycho25_YfFromLMS(float3 lms) { + return max( + psycho25_SignedYfFromLMS(lms), + PSYCHO25_EPSILON); +} + +// Map a signed LMS input onto its D65-relative CIE 170-2 hue ray while +// retaining the absolute-L/M Yf magnitude already used by Test25 grading. +// This is not radiant energy: Yf is the weighted L+M coordinate and excludes +// S. Signed L+M supplies the source ray when defined; its absolute-LMS ray is +// the fallback when the signed denominator is degenerate. +float3 psycho25_AlignInputToCIE1702Hue(float3 lms_input) { + float3 lms_weighted = + renodx::color::macleod_boynton::WeighLMS(lms_input); + float3 lms_weighted_absolute = abs(lms_weighted); + float absolute_yf = + lms_weighted_absolute.x + lms_weighted_absolute.y; + if (!(absolute_yf > PSYCHO25_EPSILON)) { + return 0.f.xxx; + } + + float signed_yf = lms_weighted.x + lms_weighted.y; + float2 source_ls = abs(signed_yf) > PSYCHO25_EPSILON + ? float2(lms_weighted.x, lms_weighted.z) / signed_yf + : float2(lms_weighted_absolute.x, lms_weighted_absolute.z) + / absolute_yf; + float2 white_ls = renodx::color::gamut::CIE1702WhiteChromaticity(); + float2 direction = source_ls - white_ls; + float t_final = 1.f; + if (dot(direction, direction) + > renodx::color::gamut::MB_NEAR_WHITE_EPSILON) { + t_final = min( + 1.f, + renodx::color::gamut::RayExitTCIE1702PreciseD(direction)); + } + + return renodx::color::macleod_boynton::UnweighLMS( + renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton( + white_ls + t_final * direction, + absolute_yf)); +} + +float3 psycho25_ApplyInputPreStep(float3 lms_input, int input_pre_step) { + if (input_pre_step == PSYCHO25_INPUT_PRESTEP_POSITIVE_LMS) { + return max(lms_input, 0.f.xxx); + } + if (input_pre_step == PSYCHO25_INPUT_PRESTEP_CIE1702) { + return renodx::color::gamut::GamutCompressLMSPrecise(lms_input); + } + if (input_pre_step == PSYCHO25_INPUT_PRESTEP_CIE1702_ABSOLUTE_YF) { + return psycho25_AlignInputToCIE1702Hue(lms_input); + } + return lms_input; +} + +float3 psycho25_ApplyObserverGamutCompression( + float3 contrast_lms, + int observer_gamut_mode) { + if (observer_gamut_mode == PSYCHO25_OBSERVER_GAMUT_CIE1702) { + return renodx::color::gamut::GamutCompressLMSPrecise(contrast_lms); + } + return contrast_lms; +} + +// Apply the optional fixed-observer constraint to actual LMS immediately +// after independent per-cone contrast. Graph candidates use this same stage, +// so an observer-invalid candidate cannot define the authored hue field. +float3 psycho25_ApplyContrastResponse( + float3 lms_input, + float3 anchor_in, + float3 anchor_out, + float contrast_power, + int observer_gamut_mode) { + float3 contrast_lms = anchor_out + * renodx::math::SignPow( + lms_input / anchor_in, + contrast_power); + return psycho25_ApplyObserverGamutCompression( + contrast_lms, + observer_gamut_mode); +} + +float psycho25_GradeQuinticUnitRamp(float t) { + t = saturate(t); + return t * t * t * (t * (t * 6.f - 15.f) + 10.f); +} + +Psycho25ConeResponseParameters psycho25_PrepareConeResponseParameters( + float3 anchor_out, + float3 lms_peak, + float contrast_power, + float compression_power, + float encoded_response_power) { + Psycho25ConeResponseParameters parameters; + parameters.anchor_out = max(anchor_out, PSYCHO25_EPSILON.xxx); + float3 anchor_over_peak = parameters.anchor_out / lms_peak; + float3 anchor_peak_power = pow( + anchor_over_peak, + compression_power); + float3 compression_slope_norm = 1.f - anchor_peak_power; + parameters.compression_exponent = compression_power + / compression_slope_norm; + parameters.encoded_response_power = max( + encoded_response_power, + PSYCHO25_EPSILON); + parameters.input_response_exponent = max( + contrast_power, + PSYCHO25_EPSILON) + * parameters.compression_exponent + * parameters.encoded_response_power; + // (peak / anchor)^h - 1 == 1 / (anchor / peak)^h - 1. + parameters.encoded_peak_offset = rcp(anchor_peak_power) - 1.f; + parameters.inverse_compression_power = rcp(compression_power); + return parameters; +} + +// Scalar RenoDX v4 highlight grade. +// highlights > 1 increases highlights; highlights < 1 reduces them. +// The adapted anchor is an exact fixed point. +float psycho25_HighlightsScalarV4( + float x, + float highlights, + float adapted_anchor_yf) { + if (highlights == 1.f) return x; + + float t = 0.f; + if (x > adapted_anchor_yf) { + float reference_range_log2 = log2( + PSYCHO25_HIGHLIGHT_GRADE_REFERENCE_WHITE + / max(adapted_anchor_yf, PSYCHO25_EPSILON)); + t = saturate( + log2(x / max(adapted_anchor_yf, PSYCHO25_EPSILON)) + / max(reference_range_log2, PSYCHO25_EPSILON)); + } + t = psycho25_GradeQuinticUnitRamp(t); + + float ratio = max( + x / max(adapted_anchor_yf, PSYCHO25_EPSILON), + PSYCHO25_EPSILON); + if (highlights > 1.f) { + return lerp( + x, + adapted_anchor_yf * pow(ratio, highlights), + t); + } + + float b = adapted_anchor_yf * pow(ratio, 2.f - highlights); + return renodx::math::DivideSafe(x * x, lerp(x, b, t), x); +} + +// Scalar RenoDX v4 shadow grade. +// shadows > 1 brightens shadows; shadows < 1 darkens them. +// The adapted anchor is an exact fixed point; the mask reaches full strength +// at the deep-shadow reference. +float psycho25_ShadowsScalarV4( + float x, + float shadows, + float adapted_anchor_yf) { + if (shadows == 1.f) return x; + + float ratio = max( + renodx::math::DivideSafe(x, adapted_anchor_yf, 0.f), + 0.f); + float base_term = x * adapted_anchor_yf; + float base_scale = renodx::math::DivideSafe(base_term, ratio, 0.f); + float shadow_floor = + adapted_anchor_yf * exp2(-PSYCHO25_SHADOW_GRADE_RANGE_STOPS); + + float t = 1.f; + if (x > shadow_floor) { + t = saturate( + log2(x / max(adapted_anchor_yf, PSYCHO25_EPSILON)) + / log2( + shadow_floor + / max(adapted_anchor_yf, PSYCHO25_EPSILON))); + } + t = psycho25_GradeQuinticUnitRamp(t); + + if (shadows > 1.f) { + float raised = x * (1.f + renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO25_EPSILON), shadows), 0.f)); + float reference = x * (1.f + base_scale); + return x + (raised - reference) * t; + } + + float lowered = x * (1.f - renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO25_EPSILON), 2.f - shadows), 0.f)); + float reference = x * (1.f - base_scale); + return x + (lowered - reference) * t; +} + +float psycho25_AutoCompressionFromCenteredReferenceRange( + float anchor_out_yf, + float peak_yf) { + float peak_over_anchor = peak_yf / anchor_out_yf; + + float reference_one_side_range_log10 = + PSYCHO25_REFERENCE_SIMULTANEOUS_RANGE_LOG10 + / PSYCHO25_REFERENCE_CENTERED_RANGE_SIDE_COUNT; + float actual_above_adaptation_range_log10 = log10(peak_over_anchor); + return max( + reference_one_side_range_log10 + / actual_above_adaptation_range_log10, + PSYCHO25_MIN_AUTO_COMPRESSION); +} + +float psycho25_ResolveGuidancePeakYf( + float target_peak_yf, + float guidance_peak_scale) { + return target_peak_yf + * max(guidance_peak_scale, PSYCHO25_MIN_GUIDANCE_PEAK_SCALE); +} + +float3 psycho25_ToAdaptiveRelativeWeightedLMS( + float3 lms_input, + float3 current_adaptive_state_lms) { + return renodx::math::DivideSafe( + renodx::color::macleod_boynton::WeighLMS(lms_input), + current_adaptive_state_lms, + 0.f.xxx); +} + +float3 psycho25_FromAdaptiveRelativeWeightedLMS( + float3 lms_weighted_relative, + float3 current_adaptive_state_lms) { + return lms_weighted_relative + * max(current_adaptive_state_lms, PSYCHO25_EPSILON.xxx); +} + +float3 psycho25_LMSFromAdaptiveMB( + float3 mb, + float3 current_adaptive_state_lms) { + float3 relative_weighted = + renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton(mb); + return renodx::color::macleod_boynton::UnweighLMS( + psycho25_FromAdaptiveRelativeWeightedLMS( + relative_weighted, + current_adaptive_state_lms)); +} + +float3 psycho25_ApplyAdaptiveMBPurity( + float3 lms_input, + float3 adaptive_neutral_lms, + float purity_delta) { + if (abs(purity_delta - 1.f) <= 1e-5f) return lms_input; + + float3 relative_weighted = + psycho25_ToAdaptiveRelativeWeightedLMS( + lms_input, + adaptive_neutral_lms); + float3 mb = + renodx::color::macleod_boynton::from::WeightedLMS( + relative_weighted); + float3 mb_neutral = + renodx::color::macleod_boynton::from::LMS(1.f.xxx); + float2 mb_scaled_xy = lerp(mb_neutral.xy, mb.xy, purity_delta); + float3 relative_weighted_out = + renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton( + float3(mb_scaled_xy, mb.z)); + return renodx::color::macleod_boynton::UnweighLMS( + psycho25_FromAdaptiveRelativeWeightedLMS( + relative_weighted_out, + adaptive_neutral_lms)); +} + +float2 psycho25_AdaptiveMBDirection( + float3 lms_input, + float3 current_adaptive_state_lms, + float2 adapted_neutral_mb) { + float3 relative_weighted = + psycho25_ToAdaptiveRelativeWeightedLMS( + lms_input, + current_adaptive_state_lms); + float3 mb = + renodx::color::macleod_boynton::from::WeightedLMS( + relative_weighted); + float2 offset = mb.xy - adapted_neutral_mb; + float radius2 = dot(offset, offset); + if (radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) return 0.f.xx; + return offset * rsqrt(radius2); +} + +float2 psycho25_IsolatedConeDisplacementAxis( + float3 current_adaptive_state_lms, + float2 adapted_neutral_mb, + uint cone_index) { + float3 displaced_lms = current_adaptive_state_lms; + if (cone_index == 0u) { + displaced_lms.x *= 2.f; + } else if (cone_index == 1u) { + displaced_lms.y *= 2.f; + } else { + displaced_lms.z *= 2.f; + } + return psycho25_AdaptiveMBDirection( + displaced_lms, + current_adaptive_state_lms, + adapted_neutral_mb); +} + +Psycho25HueSection psycho25_HuePinIntervalForAngle( + float source_hue_angle, + float2 axis_l, + float2 axis_m, + float2 axis_s) { + // The raw per-cone field is exactly zero on each isolated-cone axis and its + // antipode. These rays delimit inversion intervals so every cone-axis pin is + // retained without a separate dominance-order topology. + float pin_l = psycho25_PositiveHueAngle(atan2(axis_l.y, axis_l.x)); + float pin_m = psycho25_PositiveHueAngle(atan2(axis_m.y, axis_m.x)); + float pin_s = psycho25_PositiveHueAngle(atan2(axis_s.y, axis_s.x)); + float pin_minus_l = psycho25_PositiveHueAngle(pin_l + PSYCHO25_PI); + float pin_minus_m = psycho25_PositiveHueAngle(pin_m + PSYCHO25_PI); + float pin_minus_s = psycho25_PositiveHueAngle(pin_s + PSYCHO25_PI); + + float angle = psycho25_PositiveHueAngle(source_hue_angle); + Psycho25HueSection interval; + if (angle >= pin_l || angle < pin_minus_m) { + interval.start = pin_l; + interval.end = pin_minus_m + PSYCHO25_TWO_PI; + interval.source_unwrapped = angle < pin_minus_m + ? angle + PSYCHO25_TWO_PI + : angle; + interval.index = 0u; + } else if (angle < pin_s) { + interval.start = pin_minus_m; + interval.end = pin_s; + interval.source_unwrapped = angle; + interval.index = 1u; + } else if (angle < pin_minus_l) { + interval.start = pin_s; + interval.end = pin_minus_l; + interval.source_unwrapped = angle; + interval.index = 2u; + } else if (angle < pin_m) { + interval.start = pin_minus_l; + interval.end = pin_m; + interval.source_unwrapped = angle; + interval.index = 3u; + } else if (angle < pin_minus_s) { + interval.start = pin_m; + interval.end = pin_minus_s; + interval.source_unwrapped = angle; + interval.index = 4u; + } else { + interval.start = pin_minus_s; + interval.end = pin_l; + interval.source_unwrapped = angle; + interval.index = 5u; + } + interval.midpoint = 0.5f * (interval.start + interval.end); + return interval; +} + +Psycho25HueEvaluationContext psycho25_PrepareHueEvaluationContext( + Psycho25ConeResponseParameters guidance_cone_response, + float3 current_adaptive_state_lms, + float3 anchor_in, + float3 anchor_out, + float3 guidance_lms_peak, + float2 adapted_neutral_mb, + float source_radius, + float source_target_yf, + float contrast_power, + int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { + Psycho25HueEvaluationContext context; + context.guidance_cone_response = guidance_cone_response; + context.current_adaptive_state_lms = current_adaptive_state_lms; + context.anchor_in = anchor_in; + context.anchor_out = anchor_out; + context.guidance_lms_peak = guidance_lms_peak; + context.adapted_neutral_mb = adapted_neutral_mb; + context.source_radius = source_radius; + context.source_target_yf = source_target_yf; + context.contrast_power = contrast_power; + context.observer_gamut_mode = observer_gamut_mode; + return context; +} + +float3x3 psycho25_WeightedLMSToRGBMatrix( + int gamut_mode) { + return gamut_mode == 0 + ? renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT709_MAT + : renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT2020_MAT; +} + +float3 psycho25_TargetRGBFromLMS( + float3 lms, + int gamut_mode) { + return mul( + psycho25_WeightedLMSToRGBMatrix(gamut_mode), + renodx::color::macleod_boynton::WeighLMS(lms)); +} + + float3 psycho25_LMSFromTargetRGB( + float3 target_rgb, + int gamut_mode) { + return gamut_mode == 0 + ? renodx::color::lms::from::BT709(target_rgb) + : renodx::color::lms::from::BT2020(target_rgb); + } + +float psycho25_TargetLowerPlaneBoundaryFraction( + float3 candidate_target_rgb, + float3 neutral_target_rgb) { + float boundary_fraction = PSYCHO25_LARGE; + if (candidate_target_rgb.x < neutral_target_rgb.x) { + boundary_fraction = min( + boundary_fraction, + neutral_target_rgb.x + / (neutral_target_rgb.x - candidate_target_rgb.x)); + } + if (candidate_target_rgb.y < neutral_target_rgb.y) { + boundary_fraction = min( + boundary_fraction, + neutral_target_rgb.y + / (neutral_target_rgb.y - candidate_target_rgb.y)); + } + if (candidate_target_rgb.z < neutral_target_rgb.z) { + boundary_fraction = min( + boundary_fraction, + neutral_target_rgb.z + / (neutral_target_rgb.z - candidate_target_rgb.z)); + } + return boundary_fraction; +} + + float3 psycho25_PullBackAdaptiveMBToTargetLowerPlanes( + float3 candidate_mb, + float2 adapted_neutral_mb, + float3 current_adaptive_state_lms, + int target_gamut_mode) { + float3 neutral_lms = psycho25_LMSFromAdaptiveMB( + float3(adapted_neutral_mb, candidate_mb.z), + current_adaptive_state_lms); + float3 candidate_lms = psycho25_LMSFromAdaptiveMB( + candidate_mb, + current_adaptive_state_lms); + float boundary_fraction = psycho25_TargetLowerPlaneBoundaryFraction( + psycho25_TargetRGBFromLMS(candidate_lms, target_gamut_mode), + psycho25_TargetRGBFromLMS(neutral_lms, target_gamut_mode)); + candidate_mb.xy = lerp( + adapted_neutral_mb, + candidate_mb.xy, + saturate(boundary_fraction)); + return candidate_mb; + } + +float psycho25_CompressTargetLowerPlaneRadius( + float boundary_fraction) { + float knee = PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE + * boundary_fraction; + float headroom = boundary_fraction - knee; + float excess = max(1.f - knee, 0.f); + return 1.f - excess + + renodx::math::DivideSafe( + headroom * excess, + headroom + excess, + 0.f); +} + +float psycho25_SmoothPositive(float value) { + float smooth_length = sqrt( + value * value + + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON + * PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); + float normalized_value = value / smooth_length; + return 0.5f + * value + * normalized_value + * (1.f + normalized_value); +} + +float psycho25_IntersectTargetPlaneSupports(float a, float b) { + float normalization = max(a, b); + float normalized_a = a / normalization; + float normalized_b = b / normalization; + float denominator = normalization * pow(pow(normalized_a, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER) + pow(normalized_b, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER), rcp(PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER)); + return a * b / denominator; +} + +float psycho25_IntersectTargetPlaneSupports(float3 support) { + return psycho25_IntersectTargetPlaneSupports( + support.x, + psycho25_IntersectTargetPlaneSupports( + support.y, + support.z)); +} + +float3 psycho25_LiftTargetRGBTowardWhite( + float3 candidate_lms, + float white_level, + int target_gamut_mode) { + float3 candidate_target_rgb = psycho25_TargetRGBFromLMS( + candidate_lms, + target_gamut_mode); + float3 white_target_rgb = white_level.xxx; + + // For q = lerp(candidate, white, t), each negative channel requires + // t >= -candidate / (white - candidate). SmoothPositiveMajorant is strictly + // no smaller than max(required, 0), and the bounded transform retains that + // conservative property. The union is smooth across active target planes + // and remains at least as large as every channel's required lift. + float3 required_lift = -candidate_target_rgb / max( + white_target_rgb - candidate_target_rgb, + PSYCHO25_EPSILON.xxx); + float3 smooth_positive_majorant = 0.5f * ( + required_lift + + sqrt( + required_lift * required_lift + + PSYCHO25_WHITE_LIFT_PRESSURE_EPSILON + * PSYCHO25_WHITE_LIFT_PRESSURE_EPSILON)); + float3 channel_lift = smooth_positive_majorant / ( + 1.f + smooth_positive_majorant - required_lift); + float minimum_white_lift = 1.f + - (1.f - channel_lift.x) + * (1.f - channel_lift.y) + * (1.f - channel_lift.z); + + // The minimum lift lands an outside point on its limiting lower plane. + // For fixed-ray occupancy s and t = 1 - 1/s, multiplying the remaining + // displacement by 1 - t^2 maps the result to occupancy 1 - t^2 inside that + // plane. It leaves the boundary with zero first-order inward motion, then + // converges to white as pressure grows instead of flattening the outside + // trajectory onto a target wall. The scalar residual keeps the selected- + // target D65-relative direction intact; the Reference2 wrapper below + // restores the exact Graph-authored adaptive-MB hue after the move. + float white_residual = (1.f - minimum_white_lift) + * (1.f - minimum_white_lift * minimum_white_lift); + float3 output_target_rgb = white_target_rgb + + white_residual * (candidate_target_rgb - white_target_rgb); + return psycho25_LMSFromTargetRGB( + output_target_rgb, + target_gamut_mode); +} + +float3 psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( + float3 candidate_lms, + float3 current_adaptive_state_lms, + float white_level, + int target_gamut_mode) { + float3 lifted_lms = psycho25_LiftTargetRGBTowardWhite( + candidate_lms, + white_level, + target_gamut_mode); + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 lifted_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + lifted_lms, + current_adaptive_state_lms)); + float2 candidate_direction = psycho25_AdaptiveMBDirection( + candidate_lms, + current_adaptive_state_lms, + adapted_neutral_mb); + float2 output_mb_xy = adapted_neutral_mb + + candidate_direction + * length(lifted_mb.xy - adapted_neutral_mb); + float output_yf = psycho25_YfFromLMS(lifted_lms); + float output_mb_scale = renodx::math::DivideSafe( + output_yf, + output_mb_xy.x * current_adaptive_state_lms.x + + (1.f - output_mb_xy.x) * current_adaptive_state_lms.y, + 0.f); + return psycho25_LMSFromAdaptiveMB( + float3(output_mb_xy, output_mb_scale), + current_adaptive_state_lms); +} + +float3 psycho25_CompressTargetHueTriangleVolume( + float3 preferred_lms, + float peak_value, + int target_gamut_mode) { + float safe_peak = max(peak_value, PSYCHO25_EPSILON); + float3 preferred_target_rgb = psycho25_TargetRGBFromLMS( + preferred_lms, + target_gamut_mode); + float minimum_channel = min( + preferred_target_rgb.x, + min(preferred_target_rgb.y, preferred_target_rgb.z)); + float maximum_channel = max( + preferred_target_rgb.x, + max(preferred_target_rgb.y, preferred_target_rgb.z)); + float channel_range = maximum_channel - minimum_channel; + + // For target RGB x, C = peak * (x - min(x)) / (max(x) - min(x)) + // is the exact hue-rim point with min(C)=0 and max(C)=peak. The raw + // barycentric weights reproduce every in-cube point exactly: + // x = black_weight * 0 + hue_weight * C + white_weight * peak.xxx. + float3 hue_rim_target_rgb = safe_peak + * (preferred_target_rgb - minimum_channel.xxx) + / max(channel_range, PSYCHO25_EPSILON); + float3 raw_weights = float3( + 1.f - maximum_channel / safe_peak, + channel_range / safe_peak, + minimum_channel / safe_peak); + + // Smoothly project invalid barycentric coordinates into the target + // triangle. This is effectively identity for positive in-volume weights + // and evaluates every simplex face together without authored section tests. + float3 positive_weights = float3( + psycho25_SmoothPositive(raw_weights.x), + psycho25_SmoothPositive(raw_weights.y), + psycho25_SmoothPositive(raw_weights.z)); + float3 contained_weights = positive_weights + / max( + positive_weights.x + positive_weights.y + positive_weights.z, + PSYCHO25_EPSILON); + + // Negative black weight means an upper-plane violation; negative white + // weight means a lower-plane violation. The squared pressure has zero slope + // at first contact, then approaches one under extreme pressure. Moving the + // contained point toward the triangle's white vertex makes white—not a dark + // target wall—the terminal fallback for either class of violation. + float upper_pressure = psycho25_SmoothPositive(-raw_weights.x); + float lower_pressure = psycho25_SmoothPositive(-raw_weights.z); + float upper_white_weight = upper_pressure * upper_pressure + / (1.f + upper_pressure * upper_pressure); + float lower_white_weight = lower_pressure * lower_pressure + / (1.f + lower_pressure * lower_pressure); + float pressure_white_weight = 1.f + - (1.f - upper_white_weight) * (1.f - lower_white_weight); + contained_weights = lerp( + contained_weights, + float3(0.f, 0.f, 1.f), + pressure_white_weight); + + float3 output_target_rgb = contained_weights.y * hue_rim_target_rgb + + contained_weights.z * safe_peak.xxx; + return psycho25_LMSFromTargetRGB( + output_target_rgb, + target_gamut_mode); +} + +float psycho25_TargetLowerPlaneRadiusForDirection( + float2 direction, + float2 adapted_neutral_mb, + float3 current_adaptive_state_lms, + int target_gamut_mode) { + float3 neutral_lms = psycho25_LMSFromAdaptiveMB( + float3(adapted_neutral_mb, 1.f), + current_adaptive_state_lms); + float3 unit_radius_lms = psycho25_LMSFromAdaptiveMB( + float3(adapted_neutral_mb + direction, 1.f), + current_adaptive_state_lms); + float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( + neutral_lms, + target_gamut_mode); + float3 direction_target_rgb = psycho25_TargetRGBFromLMS( + unit_radius_lms - neutral_lms, + target_gamut_mode); + float3 lower_support = neutral_target_rgb / (float3(psycho25_SmoothPositive(-direction_target_rgb.x), psycho25_SmoothPositive(-direction_target_rgb.y), psycho25_SmoothPositive(-direction_target_rgb.z)) + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); + return psycho25_IntersectTargetPlaneSupports(lower_support); +} + + float3 psycho25_CompressSectionalWhiteVolume( + float3 preferred_lms, + float3 current_adaptive_state_lms, + float peak_value, + int target_gamut_mode, + int gamut_enforcement) { + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + if (!enforce_gamut_primaries && !enforce_gamut_peak) { + return preferred_lms; + } + + // Hold the preferred physical result's Yf fixed and measure its selected- + // target RGB displacement from the D65 axis at that same Yf. Scaling this + // displacement therefore changes only the target-RGB color direction and + // magnitude, not the already-authored achromatic response. + float preferred_yf = psycho25_YfFromLMS(preferred_lms); + float3 neutral_lms = current_adaptive_state_lms + * renodx::math::DivideSafe( + preferred_yf, + psycho25_YfFromLMS(current_adaptive_state_lms), + 0.f); + float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( + neutral_lms, + target_gamut_mode); + float3 preferred_target_rgb = psycho25_TargetRGBFromLMS( + preferred_lms, + target_gamut_mode); + float3 target_displacement = preferred_target_rgb - neutral_target_rgb; + + // Each normalized occupancy is zero when its plane is not approached and + // one where the uncompressed displacement reaches that plane. Their L8 norm + // is a smooth conservative union of all enabled cube faces: it is never + // smaller than any individual occupancy, including at face/edge ties. + float3 lower_occupancy = 0.f.xxx; + float3 upper_occupancy = 0.f.xxx; + if (enforce_gamut_primaries) { + lower_occupancy = max(-target_displacement, 0.f.xxx) + / max(neutral_target_rgb, PSYCHO25_EPSILON.xxx); + } + if (enforce_gamut_peak) { + upper_occupancy = max(target_displacement, 0.f.xxx) + / max( + peak_value.xxx - neutral_target_rgb, + PSYCHO25_EPSILON.xxx); + } + float3 lower_occupancy_power = pow( + lower_occupancy, + PSYCHO25_SECTIONAL_VOLUME_POWER.xxx); + float3 upper_occupancy_power = pow( + upper_occupancy, + PSYCHO25_SECTIONAL_VOLUME_POWER.xxx); + float occupancy_power_sum = + lower_occupancy_power.x + + lower_occupancy_power.y + + lower_occupancy_power.z + + upper_occupancy_power.x + + upper_occupancy_power.y + + upper_occupancy_power.z; + + // One global saturation response replaces a black-to-wall/white handoff. + // It is nearly identity inside the cube, maps a single-face occupancy of one + // to pow(2, -1/8), and asymptotically approaches every active boundary from + // inside without a final component clamp. + float displacement_scale = pow( + 1.f + occupancy_power_sum, + -rcp(PSYCHO25_SECTIONAL_VOLUME_POWER)); + return psycho25_LMSFromTargetRGB( + neutral_target_rgb + target_displacement * displacement_scale, + target_gamut_mode); + } + +float3 psycho25_LMSFromHueDirectionAndYf( + float2 direction, + float source_radius, + float source_target_yf, + float3 current_adaptive_state_lms, + float2 adapted_neutral_mb) { + float3 candidate = psycho25_LMSFromAdaptiveMB( + float3(adapted_neutral_mb + direction * source_radius, 1.f), + current_adaptive_state_lms); + float candidate_yf = psycho25_YfFromLMS(candidate); + return candidate * renodx::math::DivideSafe(source_target_yf, candidate_yf, 1.f); +} + + +// Exact selected-target radial support at one adaptive-MB hue and physical Yf. +// Test25's adaptation-relative MB reconstruction makes target RGB a linear- +// fractional function of radius rather than a simple affine ray. Each enabled +// RGB cube face still has one closed-form scalar intersection, so no per-pixel +// search or LUT is required. +float psycho25_TargetRadialSupportAtYf( + float2 direction, + float target_yf, + float3 current_adaptive_state_lms, + float2 adapted_neutral_mb, + float peak_value, + int target_gamut_mode, + int gamut_enforcement) { + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + if (!enforce_gamut_primaries && !enforce_gamut_peak) { + return PSYCHO25_LARGE; + } + + // Along an adaptive-MB radial line + // l(r) = l0 + r*dl, s(r) = s0 + r*ds, + // the adaptation-relative weighted-LMS numerator is affine in r. Restoring + // absolute LMS and then fixing physical Yf divides by the affine L+M term, + // so each target RGB channel is a linear-fractional function: + // + // RGB_i(r) = target_yf * (N0_i + r*N1_i) / (D0 + r*D1). + // + // Intersecting RGB_i(r) with a lower plane 0 or upper plane peak therefore + // has one closed-form positive root. This is exact for Test25's adaptive-MB + // construction and avoids a per-pixel binary search. + float3 relative_weighted_zero = float3( + adapted_neutral_mb.x, + 1.f - adapted_neutral_mb.x, + adapted_neutral_mb.y); + float3 relative_weighted_delta = float3( + direction.x, + -direction.x, + direction.y); + float3 physical_weighted_zero = + relative_weighted_zero * current_adaptive_state_lms; + float3 physical_weighted_delta = + relative_weighted_delta * current_adaptive_state_lms; + float denominator_zero = + physical_weighted_zero.x + physical_weighted_zero.y; + float denominator_delta = + physical_weighted_delta.x + physical_weighted_delta.y; + + float3x3 weighted_lms_to_target_rgb = + psycho25_WeightedLMSToRGBMatrix(target_gamut_mode); + float3 numerator_zero = mul( + weighted_lms_to_target_rgb, + physical_weighted_zero); + float3 numerator_delta = mul( + weighted_lms_to_target_rgb, + physical_weighted_delta); + + float support = PSYCHO25_LARGE; + + if (enforce_gamut_primaries) { + // target_yf*(N0 + r*N1) = 0 + float3 lower_denominator = target_yf * numerator_delta; + float3 lower_numerator = -target_yf * numerator_zero; + + if (abs(lower_denominator.x) > PSYCHO25_EPSILON) { + float radius = lower_numerator.x / lower_denominator.x; + if (radius > 0.f + && denominator_zero + radius * denominator_delta + > PSYCHO25_EPSILON) { + support = min(support, radius); + } + } + if (abs(lower_denominator.y) > PSYCHO25_EPSILON) { + float radius = lower_numerator.y / lower_denominator.y; + if (radius > 0.f + && denominator_zero + radius * denominator_delta + > PSYCHO25_EPSILON) { + support = min(support, radius); + } + } + if (abs(lower_denominator.z) > PSYCHO25_EPSILON) { + float radius = lower_numerator.z / lower_denominator.z; + if (radius > 0.f + && denominator_zero + radius * denominator_delta + > PSYCHO25_EPSILON) { + support = min(support, radius); + } + } + } + + if (enforce_gamut_peak) { + // target_yf*(N0 + r*N1) = peak*(D0 + r*D1) + float3 upper_denominator = + target_yf * numerator_delta - peak_value * denominator_delta; + float3 upper_numerator = + peak_value * denominator_zero - target_yf * numerator_zero; + + if (abs(upper_denominator.x) > PSYCHO25_EPSILON) { + float radius = upper_numerator.x / upper_denominator.x; + if (radius > 0.f + && denominator_zero + radius * denominator_delta + > PSYCHO25_EPSILON) { + support = min(support, radius); + } + } + if (abs(upper_denominator.y) > PSYCHO25_EPSILON) { + float radius = upper_numerator.y / upper_denominator.y; + if (radius > 0.f + && denominator_zero + radius * denominator_delta + > PSYCHO25_EPSILON) { + support = min(support, radius); + } + } + if (abs(upper_denominator.z) > PSYCHO25_EPSILON) { + float radius = upper_numerator.z / upper_denominator.z; + if (radius > 0.f + && denominator_zero + radius * denominator_delta + > PSYCHO25_EPSILON) { + support = min(support, radius); + } + } + } + + return max(support, 0.f); +} + +// Bounded generalized-Neutwo pressure response used only after the canonical +// target occupancy exceeds one. `pivot` is measured in excess occupancy +// q - 1, `contrast` controls pressure gain, and `h` controls the shoulder. +float psycho25_CanonicalCylinderPressure( + float occupancy, + float pivot, + float contrast, + float h) { + float excess = max(occupancy - 1.f, 0.f); + if (excess <= PSYCHO25_EPSILON) return 0.f; + + float safe_pivot = max(pivot, PSYCHO25_EPSILON); + float safe_contrast = max(contrast, PSYCHO25_EPSILON); + float safe_h = max(h, PSYCHO25_EPSILON); + float normalized_excess = excess / safe_pivot; + + // Equivalent generalized-Neutwo forms chosen by magnitude avoid inf/inf + // when stress inputs produce extremely large target occupancy. + if (normalized_excess >= 1.f) { + float inverse_power = pow( + normalized_excess, + -safe_contrast * safe_h); + return pow(1.f + inverse_power, -rcp(safe_h)); + } + float z = pow(normalized_excess, safe_contrast); + return z / pow(1.f + pow(z, safe_h), rcp(safe_h)); +} + +// Canonical-cylinder device-volume experiment. +// +// 1) Convert the authored midpoint/Graph point to (theta, rho, Yf). +// 2) Normalize radius by the exact selected-target support: +// q = rho / rho_max(theta, Yf). +// 3) Keep every q <= 1 point exactly unchanged. +// 4) For q > 1, map excess pressure to w in [0,1), then move both inward in +// canonical q and upward toward peak D65 white. +// 5) Re-evaluate rho_max at the raised Yf and reconstruct the same adaptive-MB +// hue direction with rho_out = q_out * rho_max(theta, Yf_out). +// +// `trade` selects the balance: 0 = inward-first, 1 = upward/white-first. +float3 psycho25_CompressCanonicalCylinderVolume( + float3 preferred_lms, + float3 current_adaptive_state_lms, + float peak_value, + int target_gamut_mode, + int gamut_enforcement, + float pressure_pivot, + float pressure_contrast, + float pressure_h, + float pressure_trade) { + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + // This experiment is defined on the complete target RGB cube. Keep the + // independent lower-only / upper-only diagnostics on their existing paths + // rather than implicitly turning either one into full six-plane enforcement. + if (!enforce_gamut_primaries || !enforce_gamut_peak) { + return preferred_lms; + } + + float preferred_yf = psycho25_YfFromLMS(preferred_lms); + float target_peak_yf = psycho25_YfFromLMS( + psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode)); + if (!(preferred_yf > PSYCHO25_EPSILON) + || !(target_peak_yf > PSYCHO25_EPSILON)) { + return 0.f.xxx; + } + + // At or above the target's D65 peak cross-section the only full-cube point + // is peak white. This also avoids dividing by a vanishing radial support. + if (preferred_yf >= target_peak_yf * (1.f - PSYCHO25_EPSILON)) { + return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); + } + + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 preferred_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + preferred_lms, + current_adaptive_state_lms)); + float2 preferred_offset = preferred_mb.xy - adapted_neutral_mb; + float preferred_radius2 = dot(preferred_offset, preferred_offset); + if (preferred_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { + // A neutral point only needs peak containment, already handled above. + return preferred_lms; + } + + float preferred_radius = sqrt(preferred_radius2); + float2 direction = preferred_offset / preferred_radius; + float radial_support = psycho25_TargetRadialSupportAtYf( + direction, + preferred_yf, + current_adaptive_state_lms, + adapted_neutral_mb, + peak_value, + target_gamut_mode, + gamut_enforcement); + if (radial_support >= PSYCHO25_LARGE * 0.5f) { + return preferred_lms; + } + if (radial_support <= PSYCHO25_EPSILON) { + return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); + } + + float occupancy = preferred_radius / radial_support; + if (occupancy <= 1.f) { + return preferred_lms; + } + + float pressure = psycho25_CanonicalCylinderPressure( + occupancy, + pressure_pivot, + pressure_contrast, + pressure_h); + float residual = max(1.f - pressure, 0.f); + float trade = saturate(pressure_trade); + + // Exact viewer mapping: + // trade=0: q contracts rapidly while Yf rises slowly. + // trade=1: Yf rises rapidly while q contracts slowly. + float inward_power = exp2(2.f - 4.f * trade); + float upward_power = exp2(-2.f + 4.f * trade); + float output_occupancy = pow(residual, inward_power); + float preferred_y = saturate(preferred_yf / target_peak_yf); + float output_y = 1.f + - (1.f - preferred_y) * pow(residual, upward_power); + if (output_y >= 1.f - PSYCHO25_EPSILON) { + return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); + } + float output_yf = output_y * target_peak_yf; + + float output_support = psycho25_TargetRadialSupportAtYf( + direction, + output_yf, + current_adaptive_state_lms, + adapted_neutral_mb, + peak_value, + target_gamut_mode, + gamut_enforcement); + float output_radius = output_occupancy * max(output_support, 0.f); + return psycho25_LMSFromHueDirectionAndYf( + direction, + output_radius, + output_yf, + current_adaptive_state_lms, + adapted_neutral_mb); +} + + +// Canonical Yf-cone device-volume experiment. +// +// This variant keeps the same exact star-volume occupancy as Canonical +// Cylinder, but Yf controls *where gamut pressure is spent*: +// - all pressure participates in radial containment; +// - only pressure weighted by pow(Yf / peakYf, bias_power) can raise Yf. +// +// Consequently, dark saturated colors are pulled inward toward the target +// radial support without being spuriously lifted toward peak white. As Yf +// approaches target peak, the same out-of-volume pressure progressively turns +// into whiteward motion and every positive hue can still converge on peak D65. +float3 psycho25_CompressCanonicalYfConeVolume( + float3 preferred_lms, + float3 current_adaptive_state_lms, + float peak_value, + int target_gamut_mode, + int gamut_enforcement, + float pressure_pivot, + float pressure_contrast, + float pressure_h, + float yf_bias_power) { + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + if (!enforce_gamut_primaries || !enforce_gamut_peak) { + return preferred_lms; + } + + float preferred_yf = psycho25_YfFromLMS(preferred_lms); + float target_peak_yf = psycho25_YfFromLMS( + psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode)); + if (!(preferred_yf > PSYCHO25_EPSILON) + || !(target_peak_yf > PSYCHO25_EPSILON)) { + return 0.f.xxx; + } + if (preferred_yf >= target_peak_yf * (1.f - PSYCHO25_EPSILON)) { + return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); + } + + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 preferred_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + preferred_lms, + current_adaptive_state_lms)); + float2 preferred_offset = preferred_mb.xy - adapted_neutral_mb; + float preferred_radius2 = dot(preferred_offset, preferred_offset); + if (preferred_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { + return preferred_lms; + } + + float preferred_radius = sqrt(preferred_radius2); + float2 direction = preferred_offset / preferred_radius; + float radial_support = psycho25_TargetRadialSupportAtYf( + direction, + preferred_yf, + current_adaptive_state_lms, + adapted_neutral_mb, + peak_value, + target_gamut_mode, + gamut_enforcement); + if (radial_support >= PSYCHO25_LARGE * 0.5f) { + return preferred_lms; + } + if (radial_support <= PSYCHO25_EPSILON) { + return 0.f.xxx; + } + + float occupancy = preferred_radius / radial_support; + if (occupancy <= 1.f) { + return preferred_lms; + } + + float pressure = psycho25_CanonicalCylinderPressure( + occupancy, + pressure_pivot, + pressure_contrast, + pressure_h); + + float preferred_y = saturate(preferred_yf / target_peak_yf); + float safe_yf_bias_power = max(yf_bias_power, PSYCHO25_EPSILON); + float white_bias = pow(preferred_y, safe_yf_bias_power); + + // Full gamut pressure contracts canonical radius. Dark colors therefore + // spend essentially all of their correction budget radially. + float radial_residual = max(1.f - pressure, 0.f); + float output_occupancy = radial_residual; + + // Only the Yf-weighted part of pressure may move the point upward. This is + // the conical bias: whiteward motion vanishes toward black and increases + // continuously toward peak. + float white_pressure = pressure * white_bias; + float output_y = preferred_y + + (1.f - preferred_y) * white_pressure; + if (output_y >= 1.f - PSYCHO25_EPSILON) { + return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); + } + float output_yf = output_y * target_peak_yf; + + // The target cross-section changes after Yf motion, so convert the canonical + // occupancy back through the exact radial support at the new Yf. + float output_support = psycho25_TargetRadialSupportAtYf( + direction, + output_yf, + current_adaptive_state_lms, + adapted_neutral_mb, + peak_value, + target_gamut_mode, + gamut_enforcement); + float output_radius = output_occupancy * max(output_support, 0.f); + return psycho25_LMSFromHueDirectionAndYf( + direction, + output_radius, + output_yf, + current_adaptive_state_lms, + adapted_neutral_mb); +} + +Psycho25ConeResponseState psycho25_BuildConeResponseState( + float3 contrast_lms, + Psycho25ConeResponseParameters parameters) { + float3 contrast_ratio = contrast_lms / parameters.anchor_out; + + Psycho25ConeResponseState state; + state.compression_exponent = parameters.compression_exponent; + state.input_response_exponent = parameters.input_response_exponent; + state.encoded_peak_offset = parameters.encoded_peak_offset; + state.encoded_response = renodx::math::SignPow( + contrast_ratio, + parameters.compression_exponent + * parameters.encoded_response_power); + return state; +} + +Psycho25ConeResponseState psycho25_BuildConeResponseState( + float3 contrast_lms, + float3 anchor_out, + float3 lms_peak, + float contrast_power, + float compression_power, + float encoded_response_power) { + return psycho25_BuildConeResponseState( + contrast_lms, + psycho25_PrepareConeResponseParameters( + anchor_out, + lms_peak, + contrast_power, + compression_power, + encoded_response_power)); +} + +float3 psycho25_CompressionRolloffSignedPerCone( + float3 signed_contrast_lms, + Psycho25ConeResponseParameters parameters) { + Psycho25ConeResponseState response_state = + psycho25_BuildConeResponseState( + signed_contrast_lms, + parameters); + return renodx::math::SignPow( + response_state.encoded_response + / (abs(response_state.encoded_response) + + response_state.encoded_peak_offset), + parameters.inverse_compression_power); +} + +float3 psycho25_CompressionRolloffPerCone( + float3 contrast_lms, + float3 anchor_out, + float3 lms_peak, + float contrast_power, + float compression_power, + float encoded_response_power) { + return psycho25_CompressionRolloffSignedPerCone( + contrast_lms, + psycho25_PrepareConeResponseParameters( + anchor_out, + lms_peak, + contrast_power, + compression_power, + encoded_response_power)); +} + +float psycho25_CompressionRolloffScalar( + float input_value, + float anchor_out, + float peak_value, + float compression_power) { + if (input_value <= 0.f) return 0.f; + float anchor_over_peak = anchor_out / peak_value; + float anchor_peak_power = pow( + anchor_over_peak, + compression_power); + float compression_slope_norm = 1.f - anchor_peak_power; + float encoded_peak_offset = rcp(anchor_peak_power) - 1.f; + float input_response_power = compression_power + / compression_slope_norm; + float log_offset_over_input = log(max(encoded_peak_offset, 1e-30f)) + - input_response_power + * log(input_value / anchor_out); + float normalized_response = rcp( + 1.f + exp(clamp(log_offset_over_input, -80.f, 80.f))); + return peak_value * pow( + normalized_response, + rcp(compression_power)); +} + +float3 psycho25_ApplyPostTargetCompression( + float3 target_rgb, + float3 anchor_target_rgb, + float peak_value, + float compression_power, + int gamut_enforcement, + int post_compression_mode) { + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + if (enforce_gamut_primaries) { + target_rgb = max(target_rgb, 0.f.xxx); + } + if (!enforce_gamut_peak) return target_rgb; + + if (post_compression_mode == PSYCHO25_POST_COMPRESSION_PER_CHANNEL + || post_compression_mode + == PSYCHO25_POST_COMPRESSION_SOURCE_MB_PER_CHANNEL) { + float3 positive_rgb = max(target_rgb, 0.f.xxx); + float3 safe_anchor = clamp( + anchor_target_rgb, + PSYCHO25_EPSILON.xxx, + (peak_value - PSYCHO25_EPSILON).xxx); + float3 compressed_rgb = float3( + psycho25_CompressionRolloffScalar( + positive_rgb.x, + safe_anchor.x, + peak_value, + compression_power), + psycho25_CompressionRolloffScalar( + positive_rgb.y, + safe_anchor.y, + peak_value, + compression_power), + psycho25_CompressionRolloffScalar( + positive_rgb.z, + safe_anchor.z, + peak_value, + compression_power)); + return min(target_rgb, 0.f.xxx) + compressed_rgb; + } + + float max_target_channel = max( + abs(target_rgb.x), + max(abs(target_rgb.y), abs(target_rgb.z))); + if (max_target_channel <= PSYCHO25_EPSILON) return target_rgb; + float anchor_max_channel = max( + abs(anchor_target_rgb.x), + max(abs(anchor_target_rgb.y), abs(anchor_target_rgb.z))); + float compressed_max_channel = psycho25_CompressionRolloffScalar( + max_target_channel, + clamp( + anchor_max_channel, + PSYCHO25_EPSILON, + peak_value - PSYCHO25_EPSILON), + peak_value, + compression_power); + return target_rgb * (compressed_max_channel / max_target_channel); +} + +float3 psycho25_RestoreSourceAdaptiveMBDirection( + float3 candidate_lms, + float3 source_lms, + float3 current_adaptive_state_lms) { + float3 candidate_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + candidate_lms, + current_adaptive_state_lms)); + float3 source_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + source_lms, + current_adaptive_state_lms)); + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float2 candidate_offset = candidate_mb.xy - adapted_neutral_mb; + float2 source_offset = source_mb.xy - adapted_neutral_mb; + float candidate_radius2 = dot(candidate_offset, candidate_offset); + float source_radius2 = dot(source_offset, source_offset); + if (candidate_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON + || source_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { + return candidate_lms; + } + + candidate_mb.xy = adapted_neutral_mb + + source_offset * rsqrt(source_radius2) * sqrt(candidate_radius2); + return psycho25_LMSFromAdaptiveMB( + candidate_mb, + current_adaptive_state_lms); +} + + float3 psycho25_RestoreSourceBT709ResidualDirection( + float3 candidate_lms, + float3 source_lms, + float peak_value, + int target_gamut_mode, + int gamut_enforcement) { + float3 candidate_bt709 = renodx::color::bt709::from::LMS(candidate_lms); + float3 source_bt709 = renodx::color::bt709::from::LMS(source_lms); + float candidate_y = renodx::color::y::from::BT709(candidate_bt709); + float source_y = renodx::color::y::from::BT709(source_bt709); + float3 candidate_residual = candidate_bt709 - candidate_y.xxx; + float3 source_residual = source_bt709 - source_y.xxx; + float candidate_residual2 = dot(candidate_residual, candidate_residual); + float source_residual2 = dot(source_residual, source_residual); + if (candidate_residual2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON + || source_residual2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { + return candidate_lms; + } + + float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( + renodx::color::lms::from::BT709(candidate_y.xxx), + target_gamut_mode); + float3 candidate_target_rgb = psycho25_TargetRGBFromLMS( + renodx::color::lms::from::BT709( + candidate_y.xxx + + source_residual + * sqrt(candidate_residual2 / source_residual2)), + target_gamut_mode); + float3 target_residual = candidate_target_rgb - neutral_target_rgb; + float residual_scale = 1.f; + if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0) { + float3 lower_support = neutral_target_rgb + / max(-target_residual, PSYCHO25_EPSILON.xxx); + residual_scale = min( + residual_scale, + min(lower_support.x, min(lower_support.y, lower_support.z))); + } + if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0) { + float3 upper_support = (peak_value.xxx - neutral_target_rgb) + / max(target_residual, PSYCHO25_EPSILON.xxx); + residual_scale = min( + residual_scale, + min(upper_support.x, min(upper_support.y, upper_support.z))); + } + return psycho25_LMSFromTargetRGB( + neutral_target_rgb + target_residual * saturate(residual_scale), + target_gamut_mode); + } + +float3 psycho25_GamutCompressLMSBoundAdaptive( + float3 lms_input, + float3 current_adaptive_state_lms, + int target_gamut_mode, + float strength) { + float3 lms_weighted_relative = + psycho25_ToAdaptiveRelativeWeightedLMS( + lms_input, + current_adaptive_state_lms); + float3 lms_weighted_relative_out = + renodx::color::gamut::GamutCompressWeightedLMSCoreRGBBoundFromAdaptiveWeightedInput( + lms_weighted_relative, + current_adaptive_state_lms, + target_gamut_mode == 0 + ? renodx::color::macleod_boynton::BT709_TO_LMS_WEIGHTED_MAT + : renodx::color::macleod_boynton::BT2020_TO_LMS_WEIGHTED_MAT, + strength); + return renodx::color::macleod_boynton::UnweighLMS( + psycho25_FromAdaptiveRelativeWeightedLMS( + lms_weighted_relative_out, + current_adaptive_state_lms)); +} + + +bool psycho25_TargetRGBInsideEnabledHull( + float3 target_rgb, + float peak_value, + int gamut_enforcement) { + if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0 + && min(target_rgb.x, min(target_rgb.y, target_rgb.z)) < 0.f) { + return false; + } + if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0 + && max(target_rgb.x, max(target_rgb.y, target_rgb.z)) > peak_value) { + return false; + } + return true; +} + +// Bounded adaptation-relative ACHROMATIC contrast used only as a scalar +// bookkeeping metric after the ideal PsychoV result has been completed. +// +// The previous RMS-per-cone metric allowed chromatic loss to masquerade as a +// white/brightness deficit. Extremely saturated reds could therefore drift too +// far toward white simply because a legal fit removed adaptive-MB radius. +// +// Instead, the post-fit white budget is now driven only by Yf: +// +// A(Yf) = (Yf - Yf_adapt) / (abs(Yf) + abs(Yf_adapt)) +// +// This stays finite at black, is explicitly aligned with PsychoV's weighted +// LMS/Yf achromatic axis, and does not convert chromatic loss into white. +float psycho25_AchromaticYfContrast( + float3 lms, + float3 current_adaptive_state_lms) { + float signal_yf = psycho25_SignedYfFromLMS(lms); + float adapt_yf = psycho25_SignedYfFromLMS(current_adaptive_state_lms); + float safe_adapt_yf = max(abs(adapt_yf), PSYCHO25_EPSILON); + return (signal_yf - adapt_yf) + / (abs(signal_yf) + safe_adapt_yf); +} + +// Exact same-adaptive-MB-hue fit of a completed PsychoV point into the enabled +// selected-target RGB planes. +// +// The preferred physical Yf is retained whenever that Yf has a nonempty target +// cross-section. Only adaptive-MB radius is shortened, using the exact +// closed-form six-plane support already used by Test25: +// +// rho_out = min(rho_ideal, rho_max(theta, Yf)) +// +// No low-Y support approximation is used. In particular, rho_max does NOT +// collapse toward zero merely because Yf approaches black; black is reached by +// Yf -> 0 while chromaticity may remain saturated. If upper planes are enabled +// and Yf reaches the target D65 peak cross-section, peak white is the only +// legal full-cube point. +float3 psycho25_ExactAdaptiveMBTargetFit( + float3 ideal_lms, + float3 current_adaptive_state_lms, + float peak_value, + int target_gamut_mode, + int gamut_enforcement) { + if (gamut_enforcement == PSYCHO25_GAMUT_ENFORCEMENT_NONE) { + return ideal_lms; + } + + float3 ideal_target_rgb = psycho25_TargetRGBFromLMS( + ideal_lms, + target_gamut_mode); + if (psycho25_TargetRGBInsideEnabledHull( + ideal_target_rgb, + peak_value, + gamut_enforcement)) { + return ideal_lms; + } + + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + + float ideal_yf = psycho25_SignedYfFromLMS(ideal_lms); + if (!(ideal_yf > PSYCHO25_EPSILON)) { + if (enforce_gamut_primaries) { + return 0.f.xxx; + } + // Without lower-plane enforcement there is no positive-Yf ray constraint. + // Apply only the enabled upper planes as a numerical target-space fallback. + float3 target_rgb = ideal_target_rgb; + if (enforce_gamut_peak) { + target_rgb = min(target_rgb, peak_value.xxx); + } + return psycho25_LMSFromTargetRGB(target_rgb, target_gamut_mode); + } + + float3 target_peak_lms = psycho25_LMSFromTargetRGB( + peak_value.xxx, + target_gamut_mode); + float target_peak_yf = psycho25_SignedYfFromLMS(target_peak_lms); + if (enforce_gamut_peak + && ideal_yf >= target_peak_yf * (1.f - PSYCHO25_EPSILON)) { + return target_peak_lms; + } + + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 ideal_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + ideal_lms, + current_adaptive_state_lms)); + float2 ideal_offset = ideal_mb.xy - adapted_neutral_mb; + float ideal_radius2 = dot(ideal_offset, ideal_offset); + + // Neutral points have no radial degree of freedom. If one is still outside, + // only the enabled target planes can resolve it. + if (ideal_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { + float3 target_rgb = ideal_target_rgb; + if (enforce_gamut_primaries) { + target_rgb = max(target_rgb, 0.f.xxx); + } + if (enforce_gamut_peak) { + target_rgb = min(target_rgb, peak_value.xxx); + } + return psycho25_LMSFromTargetRGB(target_rgb, target_gamut_mode); + } + + float ideal_radius = sqrt(ideal_radius2); + float2 direction = ideal_offset / ideal_radius; + float radial_support = psycho25_TargetRadialSupportAtYf( + direction, + ideal_yf, + current_adaptive_state_lms, + adapted_neutral_mb, + peak_value, + target_gamut_mode, + gamut_enforcement); + + if (radial_support >= PSYCHO25_LARGE * 0.5f + || ideal_radius <= radial_support) { + return ideal_lms; + } + + float3 legal_lms = psycho25_LMSFromHueDirectionAndYf( + direction, + max(radial_support, 0.f), + ideal_yf, + current_adaptive_state_lms, + adapted_neutral_mb); + + // Exact support should already be legal. This final clamp covers only + // floating-point residue at a target plane. + float3 legal_target_rgb = psycho25_TargetRGBFromLMS( + legal_lms, + target_gamut_mode); + if (enforce_gamut_primaries) { + legal_target_rgb = max(legal_target_rgb, 0.f.xxx); + } + if (enforce_gamut_peak) { + legal_target_rgb = min(legal_target_rgb, peak_value.xxx); + } + return psycho25_LMSFromTargetRGB( + legal_target_rgb, + target_gamut_mode); +} + +// Post-ideal lost-contrast fit. +// +// 1) Complete Test25's ordinary physical/MIDPOINT result. +// 2) Fit that result to the exact selected-target six-plane support while +// retaining its adaptive-MB hue and Yf whenever the cross-section exists. +// 3) Measure only the bounded ACHROMATIC contrast lost by that legal fit: +// +// dA = max(A_ideal - A_legal, 0), +// +// where A is the Yf-based adaptation-relative contrast above. +// 4) Convert dA to a bounded Neutwo-like pressure. +// 5) Permit that pressure to become whiteward motion only in proportion to the +// square of physical Yf / target-peak Yf. +// +// Thus gamut fitting itself does not repower LMS ratios. Lost chromatic radius +// does not become white. Near black, the whiteward term vanishes quadratically +// and the exact same-hue legal result is retained. At high Yf, lost achromatic +// contrast may be spent along the legal point -> peak-D65-white segment, which +// remains inside the convex target RGB cube. +float3 psycho25_ApplyAdaptiveContrastFitLinearWhiteLegacy( + float3 ideal_lms, + float3 current_adaptive_state_lms, + float peak_value, + int target_gamut_mode, + int gamut_enforcement) { + if (gamut_enforcement == PSYCHO25_GAMUT_ENFORCEMENT_NONE) { + return ideal_lms; + } + + float3 legal_lms = psycho25_ExactAdaptiveMBTargetFit( + ideal_lms, + current_adaptive_state_lms, + peak_value, + target_gamut_mode, + gamut_enforcement); + + float ideal_contrast = psycho25_AchromaticYfContrast( + ideal_lms, + current_adaptive_state_lms); + float legal_contrast = psycho25_AchromaticYfContrast( + legal_lms, + current_adaptive_state_lms); + float lost_contrast = max(ideal_contrast - legal_contrast, 0.f); + if (lost_contrast <= PSYCHO25_EPSILON) { + return legal_lms; + } + + const bool enforce_gamut_peak = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + if (!enforce_gamut_peak) { + // Without an upper hull there is no defined target-white destination for + // the lost achromatic budget. Keep the exact same-hue fit. + return legal_lms; + } + + float3 target_peak_lms = psycho25_LMSFromTargetRGB( + peak_value.xxx, + target_gamut_mode); + float peak_contrast = psycho25_AchromaticYfContrast( + target_peak_lms, + current_adaptive_state_lms); + float available_contrast = max( + peak_contrast - legal_contrast, + PSYCHO25_EPSILON); + float normalized_loss = lost_contrast / available_contrast; + + // h=2 generalized-Neutwo occupancy: bounded [0,1), identity-like for small + // normalized loss and asymptotic under extreme out-of-hull stress. + float loss_pressure = normalized_loss + * rsqrt(1.f + normalized_loss * normalized_loss); + + float legal_yf = max(psycho25_SignedYfFromLMS(legal_lms), 0.f); + float target_peak_yf = max( + psycho25_SignedYfFromLMS(target_peak_lms), + PSYCHO25_EPSILON); + float yf_fraction = saturate(legal_yf / target_peak_yf); + float white_pressure = loss_pressure * yf_fraction * yf_fraction; + + float3 legal_target_rgb = psycho25_TargetRGBFromLMS( + legal_lms, + target_gamut_mode); + float3 output_target_rgb = lerp( + legal_target_rgb, + peak_value.xxx, + white_pressure); + + // Both endpoints are legal target-cube points, so this convex interpolation + // is legal by construction. Clamp only for floating-point residue. + if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0) { + output_target_rgb = max(output_target_rgb, 0.f.xxx); + } + output_target_rgb = min(output_target_rgb, peak_value.xxx); + return psycho25_LMSFromTargetRGB( + output_target_rgb, + target_gamut_mode); +} + +float3 psycho25_ApplyIndependentPostCompression( + float3 contrast_lms, + float3 source_lms, + float3 anchor_out, + float3 current_adaptive_state_lms, + float peak_value, + float compression_power, + int target_gamut_mode, + int gamut_enforcement, + int post_compression_mode) { + if (post_compression_mode == PSYCHO25_POST_COMPRESSION_DIRECT) { + return contrast_lms; + } + + float3 post_lms = contrast_lms; + if (post_compression_mode + == PSYCHO25_POST_COMPRESSION_SOURCE_MB_PER_CHANNEL + || post_compression_mode + == PSYCHO25_POST_COMPRESSION_SOURCE_MB_SOFT_MAX) { + post_lms = psycho25_RestoreSourceAdaptiveMBDirection( + post_lms, + source_lms, + current_adaptive_state_lms); + } + + const bool enforce_gamut_primaries = + (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + if (enforce_gamut_primaries) { + if (post_compression_mode + == PSYCHO25_POST_COMPRESSION_ADAPTIVE_MB_HARD_MAX) { + float3 post_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + post_lms, + current_adaptive_state_lms)); + post_mb = psycho25_PullBackAdaptiveMBToTargetLowerPlanes( + post_mb, + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy, + current_adaptive_state_lms, + target_gamut_mode); + post_lms = psycho25_LMSFromAdaptiveMB( + post_mb, + current_adaptive_state_lms); + } else if (post_compression_mode + == PSYCHO25_POST_COMPRESSION_ADAPTIVE_MB_SOFT_MAX + || post_compression_mode + == PSYCHO25_POST_COMPRESSION_SOURCE_MB_SOFT_MAX) { + // Match PsychoV17's final device-map helper: adaptive-relative weighted + // LMS, the selected target-primary triangle, and strength 1. + post_lms = psycho25_GamutCompressLMSBoundAdaptive( + post_lms, + current_adaptive_state_lms, + target_gamut_mode, + 1.f); + } else if (post_compression_mode + == PSYCHO25_POST_COMPRESSION_FIXED_D65_SOFT_MAX) { + post_lms = target_gamut_mode == 0 + ? renodx::color::gamut::GamutCompressLMSBoundBT709(post_lms, 1.f) + : renodx::color::gamut::GamutCompressLMSBoundBT2020(post_lms, 1.f); + } + } + + float3 post_target_rgb = psycho25_TargetRGBFromLMS( + post_lms, + target_gamut_mode); + post_target_rgb = psycho25_ApplyPostTargetCompression( + post_target_rgb, + psycho25_TargetRGBFromLMS(anchor_out, target_gamut_mode), + peak_value, + compression_power, + gamut_enforcement, + post_compression_mode); + return psycho25_LMSFromTargetRGB( + post_target_rgb, + target_gamut_mode); +} + +float psycho25_EvaluateRawPerChannelHueShift( + float source_hue_angle, + Psycho25HueEvaluationContext context) { + float2 source_direction = + float2(cos(source_hue_angle), sin(source_hue_angle)); + float3 candidate_source_lms = + psycho25_LMSFromHueDirectionAndYf( + source_direction, + context.source_radius, + context.source_target_yf, + context.current_adaptive_state_lms, + context.adapted_neutral_mb); + + float3 contrast_lms = psycho25_ApplyContrastResponse( + candidate_source_lms, + context.anchor_in, + context.anchor_out, + context.contrast_power, + context.observer_gamut_mode); + float3 candidate_guidance_lms = context.guidance_lms_peak + * psycho25_CompressionRolloffSignedPerCone( + contrast_lms, + context.guidance_cone_response); + float2 compressed_direction = + psycho25_AdaptiveMBDirection( + candidate_guidance_lms, + context.current_adaptive_state_lms, + context.adapted_neutral_mb); + if (dot(compressed_direction, compressed_direction) + <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) return 0.f; + + return atan2( + psycho25_Cross2(source_direction, compressed_direction), + dot(source_direction, compressed_direction)); +} + +Psycho25HueGeometry psycho25_FindHueGeometry( + Psycho25HueSection section, + Psycho25HueEvaluationContext context) { + float step = (section.end - section.start) + / float(PSYCHO25_HUE_PEAK_SCAN_INTERVALS); + + float best_angle = section.midpoint; + float best_shift = psycho25_EvaluateRawPerChannelHueShift( + best_angle, + context); + float best_magnitude = abs(best_shift); + + [loop] + for (uint scan = 0u; + scan <= PSYCHO25_HUE_PEAK_SCAN_INTERVALS; + ++scan) { + if (scan == PSYCHO25_HUE_PEAK_SCAN_INTERVALS / 2u) continue; + float angle = section.start + step * float(scan); + float shift = psycho25_EvaluateRawPerChannelHueShift( + angle, + context); + if (abs(shift) > best_magnitude) { + best_angle = angle; + best_shift = shift; + best_magnitude = abs(shift); + } + } + + float lo = max(section.start, best_angle - step); + float hi = min(section.end, best_angle + step); + static const float golden = 0.6180339887498948482f; + float x1 = hi - golden * (hi - lo); + float x2 = lo + golden * (hi - lo); + float shift1 = psycho25_EvaluateRawPerChannelHueShift(x1, context); + float shift2 = psycho25_EvaluateRawPerChannelHueShift(x2, context); + float y1 = abs(shift1); + float y2 = abs(shift2); + + [loop] + for (uint iteration = 0u; + iteration < PSYCHO25_HUE_PEAK_REFINE_ITERATIONS; + ++iteration) { + if (y1 < y2) { + lo = x1; + x1 = x2; + y1 = y2; + shift1 = shift2; + x2 = lo + golden * (hi - lo); + shift2 = psycho25_EvaluateRawPerChannelHueShift(x2, context); + y2 = abs(shift2); + } else { + hi = x2; + x2 = x1; + y2 = y1; + shift2 = shift1; + x1 = hi - golden * (hi - lo); + shift1 = psycho25_EvaluateRawPerChannelHueShift(x1, context); + y1 = abs(shift1); + } + } + + float refined_angle = y1 >= y2 ? x1 : x2; + float refined_shift = y1 >= y2 ? shift1 : shift2; + if (abs(refined_shift) > best_magnitude) { + best_angle = refined_angle; + best_shift = refined_shift; + best_magnitude = abs(refined_shift); + } + + float peak_offset = best_angle - section.midpoint; + Psycho25HueGeometry geometry; + geometry.peak_angle = best_angle; + geometry.peak_shift = best_shift; + geometry.active = best_magnitude > PSYCHO25_EPSILON ? 1u : 0u; + geometry.axis_slope = geometry.active != 0u + ? (abs(peak_offset) > PSYCHO25_EPSILON + ? best_shift / peak_offset + : (best_shift < 0.f ? -PSYCHO25_LARGE : PSYCHO25_LARGE)) + : -2.2f; + geometry.maximum_ordered_amplitude = 1.f; + if (geometry.active != 0u + && abs(peak_offset) > PSYCHO25_EPSILON + && section.index == 2u) { + // The raw +S-to--L field can form a sharp Yf- and purity-dependent cusp. + // Its amplitude-1 endpoint remains available, but the authored field must + // not fold hue phase. Probe both cusp sides and cap only this pin interval's + // effective amplitude with margin. For the oblique inverse + // x = t - (1 - A) r(t) / s, y = x + A r(t), + // the ordered-phase coefficient is A - (1 - A) / s. + geometry.axis_slope = min( + geometry.axis_slope, + PSYCHO25_HUE_REVERSAL_AXIS_SLOPE_LIMIT); + float derivative_step = max( + step / PSYCHO25_HUE_ORDER_DERIVATIVE_PROBE_DIVISOR, + PSYCHO25_EPSILON); + float left_angle = max(section.start, best_angle - derivative_step); + float right_angle = min(section.end, best_angle + derivative_step); + float left_shift = psycho25_EvaluateRawPerChannelHueShift( + left_angle, + context); + float right_shift = psycho25_EvaluateRawPerChannelHueShift( + right_angle, + context); + float left_derivative = renodx::math::DivideSafe( + best_shift - left_shift, + best_angle - left_angle, + 0.f); + float right_derivative = renodx::math::DivideSafe( + right_shift - best_shift, + right_angle - best_angle, + 0.f); + float minimum_raw_derivative = min(left_derivative, right_derivative); + if (minimum_raw_derivative < -PSYCHO25_EPSILON) { + float maximum_raw_coefficient = PSYCHO25_HUE_ORDER_SAFETY + / -minimum_raw_derivative; + float inverse_axis_slope = 1.f / geometry.axis_slope; + geometry.maximum_ordered_amplitude = saturate( + (maximum_raw_coefficient + inverse_axis_slope) + / (1.f + inverse_axis_slope)); + } + } + return geometry; +} + +float psycho25_ForwardMappedHue( + float curve_parameter, + float amplitude, + float axis_slope, + Psycho25HueEvaluationContext context) { + float shift = psycho25_EvaluateRawPerChannelHueShift( + curve_parameter, + context); + // The graph-space operation has the closed form + // x' = x - (1 - A) * y / slope, y' = A * y. + // Inversion needs only X; the final consumed shift is its direct Y form. + return curve_parameter - (1.f - amplitude) * shift / axis_slope; +} + +float psycho25_SolveSextantHueShift( + Psycho25HueSection section, + Psycho25HueGeometry geometry, + float amplitude, + Psycho25HueEvaluationContext context) { + if (amplitude <= PSYCHO25_EPSILON || geometry.active == 0u) return 0.f; + if (min( + section.source_unwrapped - section.start, + section.end - section.source_unwrapped) + <= PSYCHO25_EPSILON) return 0.f; + + if (amplitude >= 1.f - PSYCHO25_EPSILON) { + return psycho25_EvaluateRawPerChannelHueShift( + section.source_unwrapped, + context); + } + + // The oblique graph transform can make mapped X locally nonmonotonic even + // when the final hue phase remains ordered. A whole-interval bisection then + // changes between distant roots under tiny input perturbations. Bracket all + // sign-changing roots at a fixed resolution and invert the one nearest the + // requested source phase, which is the local branch connected to the + // amplitude-1 identity transform. + float lo = section.start; + float hi = section.end; + float lo_value = psycho25_ForwardMappedHue( + lo, + amplitude, + geometry.axis_slope, + context) + - section.source_unwrapped; + float best_distance = PSYCHO25_LARGE; + float previous_parameter = lo; + float previous_value = lo_value; + [loop] + for (uint scan = 1u; + scan <= PSYCHO25_HUE_INVERSE_BRACKET_INTERVALS; + ++scan) { + float parameter = lerp( + section.start, + section.end, + float(scan) / float(PSYCHO25_HUE_INVERSE_BRACKET_INTERVALS)); + float value = psycho25_ForwardMappedHue( + parameter, + amplitude, + geometry.axis_slope, + context) + - section.source_unwrapped; + if (previous_value * value <= 0.f) { + float estimate_fraction = saturate(renodx::math::DivideSafe( + -previous_value, + value - previous_value, + 0.5f)); + float estimated_parameter = lerp( + previous_parameter, + parameter, + estimate_fraction); + float distance = abs( + estimated_parameter - section.source_unwrapped); + if (distance < best_distance) { + lo = previous_parameter; + hi = parameter; + lo_value = previous_value; + best_distance = distance; + } + } + previous_parameter = parameter; + previous_value = value; + } + + [loop] + for (uint iteration = 0u; + iteration < PSYCHO25_HUE_INVERSE_ITERATIONS; + ++iteration) { + float midpoint = 0.5f * (lo + hi); + float midpoint_value = psycho25_ForwardMappedHue( + midpoint, + amplitude, + geometry.axis_slope, + context) + - section.source_unwrapped; + if ((lo_value < 0.f) == (midpoint_value < 0.f)) { + lo = midpoint; + lo_value = midpoint_value; + } else { + hi = midpoint; + } + } + + float curve_parameter = 0.5f * (lo + hi); + float raw_shift = psycho25_EvaluateRawPerChannelHueShift( + curve_parameter, + context); + return amplitude * raw_shift; +} + +Psycho25AdaptiveMBTrajectory psycho25_BuildAdaptiveMBTrajectory( + float3 physical_magnitude_lms, + float3 guidance_direction_lms, + float3 direction_source_lms, + float3 current_adaptive_state_lms, + float3 anchor_in, + float3 anchor_out, + float3 guidance_lms_peak, + float contrast_power, + Psycho25ConeResponseParameters guidance_cone_response, + int hue_method, + int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { + float3 magnitude_relative_weighted = + psycho25_ToAdaptiveRelativeWeightedLMS( + physical_magnitude_lms, + current_adaptive_state_lms); + float3 magnitude_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + magnitude_relative_weighted); + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + Psycho25AdaptiveMBTrajectory trajectory; + trajectory.authored_mb = magnitude_mb; + trajectory.hue_applied = 0u; + + float3 source_relative_weighted = + psycho25_ToAdaptiveRelativeWeightedLMS( + direction_source_lms, + current_adaptive_state_lms); + float3 source_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + source_relative_weighted); + float3 compressed_direction_relative_weighted = + psycho25_ToAdaptiveRelativeWeightedLMS( + guidance_direction_lms, + current_adaptive_state_lms); + float3 compressed_direction_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + compressed_direction_relative_weighted); + + float2 magnitude_offset = magnitude_mb.xy - adapted_neutral_mb; + float2 source_offset = source_mb.xy - adapted_neutral_mb; + float2 compressed_direction_offset = + compressed_direction_mb.xy - adapted_neutral_mb; + float magnitude_radius2 = dot(magnitude_offset, magnitude_offset); + float source_radius2 = dot(source_offset, source_offset); + float compressed_direction_radius2 = dot( + compressed_direction_offset, + compressed_direction_offset); + if (magnitude_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON + || source_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON + || compressed_direction_radius2 + <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { + return trajectory; + } + + float magnitude_radius = sqrt(magnitude_radius2); + float source_radius = sqrt(source_radius2); + float2 source_direction = source_offset / source_radius; + if (hue_method == PSYCHO25_HUE_METHOD_FAST_60) { + // The fixed approximately 60-degree hue-graph assumption reduces the 50% + // operation to the angular midpoint between the source and current raw + // per-channel-compressed directions. Normalizing their linear midpoint is + // exact for equal-weight unit directions and avoids all graph searches. + float2 compressed_direction = compressed_direction_offset + * rsqrt(compressed_direction_radius2); + float2 output_direction = lerp( + source_direction, + compressed_direction, + 1.f - PSYCHO25_HUE_AMPLITUDE); + float output_direction2 = dot(output_direction, output_direction); + if (output_direction2 + <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) return trajectory; + output_direction *= rsqrt(output_direction2); + trajectory.authored_mb = float3( + adapted_neutral_mb + output_direction * magnitude_radius, + magnitude_mb.z); + trajectory.hue_applied = 1u; + return trajectory; + } + + float source_hue_angle = atan2(source_direction.y, source_direction.x); + Psycho25HueEvaluationContext context = psycho25_PrepareHueEvaluationContext( + guidance_cone_response, + current_adaptive_state_lms, + anchor_in, + anchor_out, + guidance_lms_peak, + adapted_neutral_mb, + source_radius, + psycho25_YfFromLMS(direction_source_lms), + contrast_power, + observer_gamut_mode); + + float2 axis_l = psycho25_IsolatedConeDisplacementAxis( + current_adaptive_state_lms, + adapted_neutral_mb, + 0u); + float2 axis_m = psycho25_IsolatedConeDisplacementAxis( + current_adaptive_state_lms, + adapted_neutral_mb, + 1u); + float2 axis_s = psycho25_IsolatedConeDisplacementAxis( + current_adaptive_state_lms, + adapted_neutral_mb, + 2u); + Psycho25HueSection section = psycho25_HuePinIntervalForAngle( + source_hue_angle, + axis_l, + axis_m, + axis_s); + Psycho25HueGeometry geometry = psycho25_FindHueGeometry( + section, + context); + float hue_shift = psycho25_SolveSextantHueShift( + section, + geometry, + min( + PSYCHO25_HUE_AMPLITUDE, + geometry.maximum_ordered_amplitude), + context); + float output_hue_angle = source_hue_angle + hue_shift; + float2 output_direction = + float2(cos(output_hue_angle), sin(output_hue_angle)); + trajectory.authored_mb = float3( + adapted_neutral_mb + output_direction * magnitude_radius, + magnitude_mb.z); + trajectory.hue_applied = 1u; + return trajectory; +} + +// Both output branches use the same prepared cone-response state. The direct +// branch returns its saturation shoulder; the gamut-active branch retains only +// its graph-solved adaptive-MB trajectory before target-plane +// compression. +float3 psycho25_ApplyPhysicalPerConePath( + float3 desired_lms, + float3 direction_source_lms, + float3 current_adaptive_state_lms, + float3 anchor_in, + float3 anchor_out, + float3 physical_lms_peak, + float contrast_power, + Psycho25ConeResponseParameters physical_cone_response, + int hue_method, + int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { + float3 physical_compressed_lms = physical_lms_peak + * psycho25_CompressionRolloffSignedPerCone( + desired_lms, + physical_cone_response); + Psycho25AdaptiveMBTrajectory trajectory = + psycho25_BuildAdaptiveMBTrajectory( + physical_compressed_lms, + physical_compressed_lms, + direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + physical_lms_peak, + contrast_power, + physical_cone_response, + hue_method, + observer_gamut_mode); + if (trajectory.hue_applied == 0u) return physical_compressed_lms; + float3 authored_lms = psycho25_LMSFromAdaptiveMB( + trajectory.authored_mb, + current_adaptive_state_lms); + return authored_lms * renodx::math::DivideSafe( + psycho25_YfFromLMS(physical_compressed_lms), + psycho25_YfFromLMS(authored_lms), + 1.f); +} + + +// Post-ideal contrast fit that follows Test25's own physical/MIDPOINT path. +// +// The exact same-hue target fit first removes only the adaptive-MB radius that +// the selected RGB cube cannot represent at the completed physical Yf. The +// removed chromatic fraction is NOT treated as equal-energy white. Instead it +// contributes to a trajectory-advance pressure only on the high side of the +// adapted state: +// +// chroma_loss = (rho_ideal - rho_legal) / rho_ideal +// yf_gate = saturate((Yf_legal - Yf_adapt) / (Yf_peak - Yf_adapt)) +// +// Genuine lost achromatic Yf contrast contributes independently. Their smooth +// union is bounded with the h=2 Neutwo response, then converted to one later +// post-contrast magnitude. Test25's per-cone shoulder and Graph/Fast60 authoring +// are re-evaluated ONCE at that later state, after which the exact six-plane fit +// is applied again. Thus red follows the same authored red->white trajectory +// instead of a straight target-RGB lerp to white. At/below adaptation, chroma +// loss alone cannot create whiteward motion. +float3 psycho25_ApplyAdaptiveContrastFit( + float3 ideal_lms, + float3 desired_lms, + float3 direction_source_lms, + float3 current_adaptive_state_lms, + float3 anchor_in, + float3 anchor_out, + float3 target_lms_peak, + float contrast_power, + Psycho25ConeResponseParameters target_cone_response, + int hue_method, + float peak_value, + int target_gamut_mode, + int gamut_enforcement, + int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { + if (gamut_enforcement == PSYCHO25_GAMUT_ENFORCEMENT_NONE) { + return ideal_lms; + } + + float3 legal_lms = psycho25_ExactAdaptiveMBTargetFit( + ideal_lms, + current_adaptive_state_lms, + peak_value, + target_gamut_mode, + gamut_enforcement); + + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 ideal_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + ideal_lms, + current_adaptive_state_lms)); + float3 legal_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + legal_lms, + current_adaptive_state_lms)); + float ideal_radius = length(ideal_mb.xy - adapted_neutral_mb); + float legal_radius = length(legal_mb.xy - adapted_neutral_mb); + float chroma_loss_fraction = saturate( + renodx::math::DivideSafe( + max(ideal_radius - legal_radius, 0.f), + ideal_radius, + 0.f)); + + float3 target_peak_lms = psycho25_LMSFromTargetRGB( + peak_value.xxx, + target_gamut_mode); + float legal_yf = max(psycho25_SignedYfFromLMS(legal_lms), 0.f); + float adapt_yf = psycho25_SignedYfFromLMS(current_adaptive_state_lms); + float target_peak_yf = max( + psycho25_SignedYfFromLMS(target_peak_lms), + adapt_yf + PSYCHO25_EPSILON); + float high_side_yf = saturate( + renodx::math::DivideSafe( + legal_yf - adapt_yf, + target_peak_yf - adapt_yf, + 0.f)); + float chroma_pressure = chroma_loss_fraction * high_side_yf; + + float ideal_achromatic = psycho25_AchromaticYfContrast( + ideal_lms, + current_adaptive_state_lms); + float legal_achromatic = psycho25_AchromaticYfContrast( + legal_lms, + current_adaptive_state_lms); + float peak_achromatic = psycho25_AchromaticYfContrast( + target_peak_lms, + current_adaptive_state_lms); + float lost_achromatic = max( + ideal_achromatic - legal_achromatic, + 0.f); + float achromatic_pressure = saturate( + renodx::math::DivideSafe( + lost_achromatic, + max(peak_achromatic - legal_achromatic, PSYCHO25_EPSILON), + 0.f)); + + // Smooth union of chromatic and achromatic pressure. Chromatic pressure is + // already Yf-weighted above, so saturated near-black colors do not advance. + float raw_pressure = 1.f + - (1.f - chroma_pressure) * (1.f - achromatic_pressure); + if (raw_pressure <= PSYCHO25_EPSILON) { + return legal_lms; + } + + float trajectory_pressure = raw_pressure + * rsqrt(1.f + raw_pressure * raw_pressure); + float trajectory_scale = rcp(max( + 1.f - trajectory_pressure, + PSYCHO25_EPSILON)); + + // desired_lms is already post-contrast. To keep the source state used by + // Graph/Fast60 consistent with that later contrast magnitude, invert the + // scalar contrast power for the pre-contrast direction source. + float safe_contrast_power = max( + contrast_power, + PSYCHO25_EPSILON); + float source_scale = pow( + trajectory_scale, + rcp(safe_contrast_power)); + + float3 advanced_ideal_lms = psycho25_ApplyPhysicalPerConePath( + desired_lms * trajectory_scale, + direction_source_lms * source_scale, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + contrast_power, + target_cone_response, + hue_method, + observer_gamut_mode); + + return psycho25_ExactAdaptiveMBTargetFit( + advanced_ideal_lms, + current_adaptive_state_lms, + peak_value, + target_gamut_mode, + gamut_enforcement); +} + +float3 psycho25_CompressTargetHull( + float3 desired_lms, + float3 direction_source_lms, + float3 current_adaptive_state_lms, + float3 anchor_in, + float3 anchor_out, + float3 target_lms_peak, + float3 guidance_lms_peak, + float contrast_power, + float upper_plane_shoulder_power, + Psycho25ConeResponseParameters target_cone_response, + Psycho25ConeResponseParameters guidance_cone_response, + float peak_value, + int target_gamut_mode, + int gamut_enforcement, // independent lower/upper target-plane bitmask + int hue_method, + int hull_method, + int upper_hull_pivot, + float canonical_pressure_pivot, + float canonical_pressure_contrast, + float canonical_pressure_h, + float canonical_pressure_trade, + float canonical_yf_bias_power, + int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { + const bool enforce_gamut_primaries = (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; + const bool enforce_gamut_peak = (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; + float3 desired_weighted_lms = + renodx::color::macleod_boynton::WeighLMS(desired_lms); + float desired_yf = desired_weighted_lms.x + desired_weighted_lms.y; + + // Every nonblack color satisfying the selected target's lower RGB planes has + // positive Yf. A nonpositive-Yf direction therefore intersects those planes + // only at the origin. Without primary enforcement, this signed stress case + // has no stable positive-Yf hull ray, so retain the direct physical path + // instead of implicitly imposing lower planes. + if (desired_yf <= PSYCHO25_EPSILON) { + if (enforce_gamut_primaries) { + return 0.f.xxx; + } + return psycho25_ApplyPhysicalPerConePath( + desired_lms, + direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + contrast_power, + target_cone_response, + hue_method, + observer_gamut_mode); + } + + float anchor_out_yf = psycho25_YfFromLMS(anchor_out); + float target_peak_yf = psycho25_SignedYfFromLMS(target_lms_peak); + + // Keep magnitude/radius tied to the real target peak, but derive the + // compressed hue direction from the target-relative neutral guidance + // endpoint. At the 1x default this is exactly the physical endpoint and + // per-channel response. Carried scale is discarded before upper-plane + // support. + float3 physical_compressed_lms = target_lms_peak + * psycho25_CompressionRolloffSignedPerCone( + desired_lms, + target_cone_response); + float3 guidance_direction_lms = + guidance_lms_peak + * psycho25_CompressionRolloffSignedPerCone( + desired_lms, + guidance_cone_response); + Psycho25AdaptiveMBTrajectory trajectory = + psycho25_BuildAdaptiveMBTrajectory( + physical_compressed_lms, + guidance_direction_lms, + direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + guidance_lms_peak, + contrast_power, + guidance_cone_response, + hue_method, + observer_gamut_mode); + float3 safe_adaptive_state_lms = max( + current_adaptive_state_lms, + PSYCHO25_EPSILON.xxx); + float authored_yf = psycho25_YfFromLMS(physical_compressed_lms); + if (authored_yf <= PSYCHO25_EPSILON) { + if (enforce_gamut_primaries) { + return 0.f.xxx; + } + return psycho25_ApplyPhysicalPerConePath( + desired_lms, + direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + contrast_power, + target_cone_response, + hue_method, + observer_gamut_mode); + } + + float2 adapted_neutral_mb = + renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float2 authored_offset = + trajectory.authored_mb.xy - adapted_neutral_mb; + float authored_radius2 = dot(authored_offset, authored_offset); + float authored_radius = sqrt(authored_radius2); + float2 authored_direction = authored_offset * rsqrt( + authored_radius2 + PSYCHO25_EPSILON * PSYCHO25_EPSILON); + float3 source_mb = + renodx::color::macleod_boynton::from::WeightedLMS( + psycho25_ToAdaptiveRelativeWeightedLMS( + direction_source_lms, + current_adaptive_state_lms)); + + if ((hull_method == PSYCHO25_HULL_METHOD_REFERENCE_SCALE + || hull_method == PSYCHO25_HULL_METHOD_REDUCED_MAX_WHITE) + && enforce_gamut_primaries) { + // Independent cone shoulders eventually make every positive source + // approach LMS white. As that physical radius disappears, turn its + // direction continuously toward the pre-contrast source direction so + // saturated blue cannot rotate through an unrelated purple direction. + // Keep the physical radius itself unchanged so the result can continue + // through light blue to white. This is one smooth trajectory rather than + // a level- or hue-segmented correction. + float2 source_offset = source_mb.xy - adapted_neutral_mb; + float source_radius2 = dot(source_offset, source_offset); + float source_radius = sqrt(source_radius2); + float2 source_direction = source_offset * rsqrt( + source_radius2 + PSYCHO25_EPSILON * PSYCHO25_EPSILON); + float source_radius_support = + psycho25_TargetLowerPlaneRadiusForDirection( + source_direction, + adapted_neutral_mb, + current_adaptive_state_lms, + target_gamut_mode); + float source_direction_occupancy = + hull_method == PSYCHO25_HULL_METHOD_REFERENCE_SCALE + ? PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY + : PSYCHO25_REDUCED_MAX_WHITE_SOURCE_DIRECTION_OCCUPANCY; + float source_direction_support_radius = source_direction_occupancy + * source_radius_support + * renodx::math::DivideSafe( + source_radius, + sqrt( + source_radius2 + + source_radius_support * source_radius_support), + 0.f); + float radius_normalization = max( + max(authored_radius, source_direction_support_radius), + PSYCHO25_EPSILON); + float authored_weight = pow( + authored_radius / radius_normalization, + PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); + float source_direction_support_weight = pow( + source_direction_support_radius / radius_normalization, + PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); + float source_hue_support = + PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION * source_radius_support; + float source_hue_confidence = renodx::math::DivideSafe( + source_radius2, + source_radius2 + source_hue_support * source_hue_support, + 0.f); + float source_collapse_weight = renodx::math::DivideSafe( + source_direction_support_weight, + authored_weight + source_direction_support_weight, + 0.f); + float source_direction_weight = 1.f + - (1.f - source_hue_confidence) + * (1.f - source_collapse_weight); + float2 combined_direction = lerp( + authored_direction, + source_direction, + source_direction_weight); + combined_direction *= rsqrt( + dot(combined_direction, combined_direction) + + PSYCHO25_EPSILON * PSYCHO25_EPSILON); + authored_direction = combined_direction; + authored_offset = authored_direction * authored_radius; + trajectory.authored_mb.xy = adapted_neutral_mb + authored_offset; + } + + if (hull_method == PSYCHO25_HULL_METHOD_LINEAR_MB_PULLBACK + && enforce_gamut_primaries + && authored_radius > PSYCHO25_EPSILON) { + // Diagnostic path: retain the Graph/Fast60-authored adaptive-MB direction + // and actual-peak physical radius until the candidate crosses a selected- + // target lower plane, then pull that radius straight back to the first + // intersection. There is no reference radius, knee, neutral release, + // smooth support intersection, or source-direction recovery. + trajectory.authored_mb = psycho25_PullBackAdaptiveMBToTargetLowerPlanes( + trajectory.authored_mb, + adapted_neutral_mb, + current_adaptive_state_lms, + target_gamut_mode); + authored_offset = trajectory.authored_mb.xy - adapted_neutral_mb; + authored_radius = length(authored_offset); + } + + // Normalization removes the trajectory guide's carried scale. Only its + // adaptive-MB direction and radius survive into the legacy cube ray. The + // final direction is normalized only after source retention or linear + // pullback so the later physical-Yf scale cannot inherit a stale x + // coordinate. + float trajectory_yf_for_normalization = trajectory.authored_mb.z * ( + trajectory.authored_mb.x * safe_adaptive_state_lms.x + + (1.f - trajectory.authored_mb.x) * safe_adaptive_state_lms.y); + float3 unit_yf_lms = psycho25_LMSFromAdaptiveMB( + float3( + trajectory.authored_mb.xy, + renodx::math::DivideSafe( + trajectory.authored_mb.z, + trajectory_yf_for_normalization, + 0.f)), + current_adaptive_state_lms); + float3 neutral_lms = current_adaptive_state_lms + / psycho25_YfFromLMS(current_adaptive_state_lms); + + if (hull_method == PSYCHO25_HULL_METHOD_CANONICAL_CYLINDER) { + return psycho25_CompressCanonicalCylinderVolume( + unit_yf_lms * authored_yf, + current_adaptive_state_lms, + peak_value, + target_gamut_mode, + gamut_enforcement, + canonical_pressure_pivot, + canonical_pressure_contrast, + canonical_pressure_h, + canonical_pressure_trade); + } + + if (hull_method == PSYCHO25_HULL_METHOD_CANONICAL_YF_CONE) { + return psycho25_CompressCanonicalYfConeVolume( + unit_yf_lms * authored_yf, + current_adaptive_state_lms, + peak_value, + target_gamut_mode, + gamut_enforcement, + canonical_pressure_pivot, + canonical_pressure_contrast, + canonical_pressure_h, + canonical_yf_bias_power); + } + + if (hull_method == PSYCHO25_HULL_METHOD_SECTIONAL_WHITE_VOLUME) { + // The per-cone response supplies white convergence, the Graph/Fast60 + // trajectory supplies its curved 50% six-section hue direction, and this + // one cross-sectional map contracts only the same-Yf radial displacement. + // No fixed-source recovery, second tone curve, or wall-to-white post pass + // is applied afterward. + return psycho25_CompressSectionalWhiteVolume( + unit_yf_lms * authored_yf, + current_adaptive_state_lms, + peak_value, + target_gamut_mode, + gamut_enforcement); + } + + if (hull_method == PSYCHO25_HULL_METHOD_REFERENCE3 + && enforce_gamut_primaries + && enforce_gamut_peak) { + return psycho25_CompressTargetHueTriangleVolume( + unit_yf_lms * authored_yf, + peak_value, + target_gamut_mode); + } + + // The function returns a linear BT.709 representation even when the selected + // target hull is BT.2020. Negative BT.709 components are valid for colors + // outside BT.709 but inside BT.2020, so lower-plane feasibility must be + // evaluated in the selected target RGB space. Reference and Reduced Max- + // White solve against a same-authored hue reference no nearer the adaptive + // neutral than either the physical trajectory or its uncompressed post- + // contrast input. Reference2 leaves this same-Yf radial stage untouched; + // its lower-plane correction lifts the completed candidate toward D65 white. + if (enforce_gamut_primaries + && (hull_method == PSYCHO25_HULL_METHOD_REFERENCE_SCALE + || hull_method == PSYCHO25_HULL_METHOD_REDUCED_MAX_WHITE) + && authored_radius > PSYCHO25_EPSILON) { + float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( + neutral_lms, + target_gamut_mode); + + // Fixed-Yf LMS interpolation is not exactly adaptive-MB radial + // interpolation. Apply the smooth shoulder to the current ray as the final + // target-plane safeguard. + float3 current_target_rgb = psycho25_TargetRGBFromLMS( + unit_yf_lms, + target_gamut_mode); + float current_boundary_fraction = + psycho25_TargetLowerPlaneBoundaryFraction( + current_target_rgb, + neutral_target_rgb); + float current_radius_scale = + psycho25_CompressTargetLowerPlaneRadius( + current_boundary_fraction); + + authored_direction = authored_offset / authored_radius; + float containment_reference_radius = max( + authored_radius, + length(source_mb.xy - adapted_neutral_mb)); + float3 reference_lms = psycho25_LMSFromAdaptiveMB( + float3( + adapted_neutral_mb + + authored_direction * containment_reference_radius, + 1.f), + current_adaptive_state_lms); + reference_lms /= psycho25_YfFromLMS(reference_lms); + float3 reference_target_rgb = psycho25_TargetRGBFromLMS( + reference_lms, + target_gamut_mode); + + // Find the selected-target lower-plane boundary along the complete + // neutral-to-reference ray even while the reference remains in gamut. + // A boundary fraction above one means the current reference is inside. + float reference_boundary_fraction = + psycho25_TargetLowerPlaneBoundaryFraction( + reference_target_rgb, + neutral_target_rgb); + + // Compress a unit input ray with a rational shoulder whose value and + // first derivative both match identity at the knee. The output approaches + // the exact lower-plane boundary asymptotically rather than changing + // behavior when a target channel first crosses zero. + float reference_radius_scale = psycho25_CompressTargetLowerPlaneRadius( + reference_boundary_fraction); + + float trajectory_fraction = + authored_radius / containment_reference_radius; + float release_progress = saturate( + trajectory_fraction + / PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION); + float neutral_scale = min(1.f, 4.f * reference_radius_scale); + float release_weight = 1.f - release_progress; + float radius_scale = min( + lerp( + reference_radius_scale, + neutral_scale, + release_weight * release_weight), + current_radius_scale); + + unit_yf_lms = lerp(neutral_lms, unit_yf_lms, radius_scale); + } + + // With peak planes disabled, retain the no-gamut trajectory's authored Yf. + // Primary enforcement may still reduce adaptive-MB chrominance to fit the + // selected target's nonnegative RGB half-spaces. + if (!enforce_gamut_peak) { + float3 candidate_lms = unit_yf_lms * authored_yf; + if ((hull_method != PSYCHO25_HULL_METHOD_REFERENCE2 + && hull_method != PSYCHO25_HULL_METHOD_REFERENCE3) + || !enforce_gamut_primaries) { + return candidate_lms; + } + float3 candidate_target_rgb = psycho25_TargetRGBFromLMS( + candidate_lms, + target_gamut_mode); + float white_level = max( + peak_value, + max( + candidate_target_rgb.x, + max(candidate_target_rgb.y, candidate_target_rgb.z))); + return psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( + candidate_lms, + current_adaptive_state_lms, + white_level, + target_gamut_mode); + } + + float3 unit_target_rgb = psycho25_TargetRGBFromLMS( + unit_yf_lms, + target_gamut_mode); + + float max_target_channel = max( + unit_target_rgb.x, + max(unit_target_rgb.y, unit_target_rgb.z)); + float directional_yf_limit = peak_value / max_target_channel; + float shoulder_input_yf = enforce_gamut_primaries + ? desired_yf + : authored_yf; + + if (upper_hull_pivot == PSYCHO25_UPPER_HULL_PIVOT_ADAPTED_OUTPUT) { + // Experimental adapted-output pivot. Express the authored candidate as a + // displacement from the output/background anchor, measure how much of the + // available per-channel upper-plane headroom that displacement occupies, + // then apply the selected scalar shoulder power over the + // adapted-Yf-to-peak range. Scaling the complete LMS displacement keeps + // the anchor exact and avoids turning signed target RGB into a channel + // clamp. + float3 candidate_lms = unit_yf_lms * shoulder_input_yf; + float3 candidate_target_rgb = psycho25_TargetRGBFromLMS( + candidate_lms, + target_gamut_mode); + float3 anchor_target_rgb = psycho25_TargetRGBFromLMS( + anchor_out, + target_gamut_mode); + float3 target_headroom = peak_value.xxx - anchor_target_rgb; + float upper_occupancy = 0.f; + if (candidate_target_rgb.x > anchor_target_rgb.x) { + upper_occupancy = max( + upper_occupancy, + (candidate_target_rgb.x - anchor_target_rgb.x) + / target_headroom.x); + } + if (candidate_target_rgb.y > anchor_target_rgb.y) { + upper_occupancy = max( + upper_occupancy, + (candidate_target_rgb.y - anchor_target_rgb.y) + / target_headroom.y); + } + if (candidate_target_rgb.z > anchor_target_rgb.z) { + upper_occupancy = max( + upper_occupancy, + (candidate_target_rgb.z - anchor_target_rgb.z) + / target_headroom.z); + } + if (upper_occupancy <= PSYCHO25_EPSILON) { + return hull_method == PSYCHO25_HULL_METHOD_REFERENCE2 + && enforce_gamut_primaries + ? psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( + candidate_lms, + current_adaptive_state_lms, + peak_value, + target_gamut_mode) + : candidate_lms; + } + + float centered_input_yf = anchor_out_yf + + upper_occupancy + * (target_peak_yf - anchor_out_yf); + float centered_output_yf = psycho25_CompressionRolloffScalar( + centered_input_yf, + anchor_out_yf, + target_peak_yf, + upper_plane_shoulder_power); + float output_occupancy = (centered_output_yf - anchor_out_yf) + / (target_peak_yf - anchor_out_yf); + float displacement_scale = output_occupancy / upper_occupancy; + float3 output_lms = anchor_out + + (candidate_lms - anchor_out) * displacement_scale; + return hull_method == PSYCHO25_HULL_METHOD_REFERENCE2 + && enforce_gamut_primaries + ? psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( + output_lms, + current_adaptive_state_lms, + peak_value, + target_gamut_mode) + : output_lms; + } + + float normalized_input = shoulder_input_yf + * renodx::math::DivideSafe( + target_peak_yf, + directional_yf_limit, + 1.f); + float normalized_output = psycho25_CompressionRolloffScalar( + normalized_input, + anchor_out_yf, + target_peak_yf, + upper_plane_shoulder_power); + float output_yf = normalized_output + * renodx::math::DivideSafe( + directional_yf_limit, + target_peak_yf, + 1.f); + float3 output_lms = unit_yf_lms * output_yf; + return hull_method == PSYCHO25_HULL_METHOD_REFERENCE2 + && enforce_gamut_primaries + ? psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( + output_lms, + current_adaptive_state_lms, + peak_value, + target_gamut_mode) + : output_lms; +} + +// psychov-25 research source record and device-hull plan +// ------------------------------------------------------ +// +// Objective: +// PsychoV first targets the observer-side bend of the scene: +// - what state the eye adapts to, +// - how the scene is converted to contrast around that adapted state, +// - how the response is shaped around that adapted state, +// - which nonlinear curve applies at each stage. +// The human observer is not a linear gain system, so the observer model decides +// which scene differences remain important when the display hull forces +// compression. Tonemapping itself remains a device-hull problem, not an eye +// model. +// +// The design therefore distinguishes two coupled systems: +// - observer flow: a literature-backed receptor/adaptation/opponent roadmap; +// - device-hull mapping: a joint tone, hue, and gamut solve over the complete +// display hull. +// +// Current Test25 implementation status: +// - implemented: relative scene-linear BT.709 -> Stockman/CVRL LMS, +// weighted-LMS/Yf/adaptive-MB bookkeeping, caller-provided adaptation +// anchors, scalar-Yf grading, adaptive-MB purity, anchor-matched contrast, +// a retained no-gamut per-cone rolloff, a numerical 50% hue-graph solve, +// actual-trajectory selected-target +// lower-plane containment, a physical no-gamut trajectory guide, +// independently selectable target lower-plane and upper-plane support, and +// one scalar peak shoulder over the resulting device-hull ray when requested; +// - planned or not implemented: absolute retinal calibration, adaptation-state +// estimation, calibrated cone-noise thresholds, absolute photopigment +// bleaching, +// ACC/DKL response, +// explicit ON/OFF splitting, pooled cortical gain, equivalent-Gaussian hue, +// and a wider sectional optimization over multiple in-sextant hull points. +// +// Rahimi-Nasrabadi et al. (Cell Reports 2021, +// doi:10.1016/j.celrep.2021.108692) validated their ONOFF image algorithm on +// calibrated grayscale images and suggested applying it to color through the +// lightness dimension. Test25 therefore keeps highlight/shadow grading on +// scalar Yf rather than independently grading L, M, and S. This citation does +// not make the current per-cone display rolloff a biological ON/OFF model. +// +// Research basis and intended human-flow model: +// +// 1) Receptor basis — implemented as a relative rendering basis. +// Stockman-Sharpe LMS with CIE 170-2 physiological luminance Yf / weighted +// LMS bookkeeping, not CIE 1931 Y. +// +// Reference split: +// - Brainard, "Colorimetry" (chapter 10): the cone stage / color-match +// foundation. Chapter 11 explicitly points back to this chapter when it +// says, "The first stage of color vision is now well understood (see +// Chap. 10)." This supports scene RGB/XYZ -> cone excitations L, M, S. +// - Stockman & Brainard (chapter 11): builds on that receptor basis for +// first-site and second-site adaptation. +// Sources: +// https://color2.psych.upenn.edu/brainard/papers/Brainard_Stockman_Colorimetry.pdf +// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf +// +// CVRL notes that cone signals are formed only after prereceptoral filtering +// by ocular media and macular pigment. Both absorb mainly at short +// wavelengths and vary substantially across observers. The transform is an +// average-observer receptor basis unless those filters are modeled +// explicitly. +// References: CVRL background hub; "Macular and lens pigments": +// http://www.cvrl.org/background.htm +// http://www.cvrl.org/database/text/intros/intromaclens.htm +// +// MacLeod-Boynton is not itself the cortical flow. It is a weighted +// cone-chromaticity representation in an equal-luminance plane with a +// separately carried achromatic scale term. In implementation notation: +// l = Lw / (Lw + Mw) +// s = Sw / (Lw + Mw) +// y = Lw + Mw +// The fixed observer-transform coefficients form weighted LMS, the Yf-like +// achromatic response, and MB coordinates from LMS. They are not adaptation, +// gain, or bleaching terms. CVRL describes the CIE physiological functions +// as linear transforms of the Stockman & Sharpe cone fundamentals. Mantiuk +// et al. describe practical LMS scaling so that L+M corresponds to +// luminance. This is the mathematical role of the weights at this stage. +// +// Reference: MacLeod & Boynton (1979), +// doi:10.1364/JOSA.69.001183; modern CIE 170-2 implementations replace ad +// hoc weights with standardized physiological cone-fundamental/luminance +// weights. +// +// Citation split for the coefficients used by the RenoDX transform: +// - explicit CIE 170-2 / physiological-weight usage: CIE/CVRL +// physiological functions, Psychtoolbox LMSToMacBoyn, and the repository +// Stockman/MacLeod-Boynton shader wiring; +// - classic or modified MB without an explicit CIE 170-2 coefficient claim: +// MacLeod & Boynton (1979), Webster & Leonard (2008); +// - LMS scaled so the achromatic term is L+M, without an explicit CIE 170-2 +// MB coefficient claim: Mantiuk et al. (2020). +// Classic MB, modified MB, and plain L+M-scaled LMS must not be cited as if +// they automatically justify the exact CIE 170-2 coefficients used here. +// Sources: +// http://www.cvrl.org/ciexyzpr.htm +// https://psychtoolbox.org/docs/LMSToMacBoyn +// https://pmc.ncbi.nlm.nih.gov/articles/PMC2657039/ +// https://www.cl.cam.ac.uk/~rkm38/pdfs/mantiuk2020practical_csf.pdf +// +// 2) Early cone adaptation — caller-provided anchors are implemented; +// adaptation estimation and a fitted physiological response are not. +// Maintain an adapting background state (L0, M0, S0, Yf0), then express the +// stimulus relative to that background before a postreceptoral transform. +// Chapter 10 gives absolute cone excitations; chapter 11 defines how they +// depend on the adapting background and become a contrast representation. +// +// Source-backed first-site math is cone-specific contrast/gain control, not +// a rule that every adapted background maps to one fixed output level. +// Stockman & Brainard write first-site L-cone contrast as: +// C_L = delta_L / (L_b + L_0) +// with analogous forms for M and S. Equivalently: +// g_L = 1 / (L_b + L_0) +// g_L * (L - L_b) = delta_L / (L_b + L_0) +// Thus the observer approximately normalizes cone signals by the adapted +// background. First-site adaptation is neither complete nor instantaneous; +// later second-site adaptation further reshapes postreceptoral signals. +// References: Stockman & Brainard (2010); Stockman et al. (JOV 2006, +// doi:10.1167/6.11.5). +// +// Webster & Leonard (2008) distinguish their "response norm," the adapting +// level that does not bias white judgments, from their "perceptual norm," the +// stimulus that appears white. Those norms tracked closely in their +// experiments, but neither is the same term as Stockman & Brainard's +// background cone excitations or Mantiuk et al.'s background responses. The +// directly modeled early state is best called the adapted background +// reference; response/perceptual norms are higher-level interpretations of +// why that reference acts as the current neutral coding state. +// Source: https://pmc.ncbi.nlm.nih.gov/articles/PMC2657039/ +// +// CVRL further notes that luminosity functions depend strongly on chromatic +// adaptation and observing conditions, whereas cone spectral sensitivities +// remain fixed until photopigment bleaching becomes significant. This is why +// Yf bookkeeping remains tied to the adapted observer state rather than a +// condition-invariant photometric curve. +// Reference: CVRL "Luminosity functions": +// http://www.cvrl.org/database/text/intros/introvl.htm +// +// 2a) Dim cone-noise regime — research plan, not implemented. +// Before rod-dominated vision, cone-mediated detection can already be +// limited by quantal/transduction noise. In this dim-but-still-cone regime, +// threshold cone contrast follows approximately De Vries-Rose behavior: in +// log-log space, threshold contrast falls with retinal illuminance at slope +// near -0.5. At higher levels the system approaches Weber-like behavior, +// where threshold contrast is roughly constant relative to the background. +// Weak scene differences may therefore disappear into a cone-noise-limited +// floor before rod vision dominates. +// Reference direction: +// - Stockman & Brainard (2010): cone-contrast space is most useful when +// first-site adaptation is in the Weber regime and less useful where +// adaptation falls short of Weber's law; +// - Angueyra & Rieke (2013): primate cone photoreceptors exhibit measurable +// phototransduction noise. +// Sources: +// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf +// https://pmc.ncbi.nlm.nih.gov/articles/PMC3815624/ +// 2b) High-light bleaching — research plan, not implemented. +// At sufficiently high retinal illuminance, a Rushton-Henry-style law in +// trolands describes per-cone pigment availability: +// p_available(I) = 1 / (1 + I / I0) +// This complements the commonly cited fraction-bleached law: +// p_bleached(I) = I / (I + I0) +// with I0 approximately 10^4.3 Td for cones. +// +// A rendering interpretation can apply availability to cone excursions +// around an adapted-white anchor so availability -> 0 approaches equal +// white at the carried achromatic level. That interpretation must not be +// confused with the current per-cone display rolloff. +// Sources and attribution: +// - Stockman et al. (JOV 2006, doi:10.1167/6.11.5): high-light sensitivity +// regulation is maintained mainly by photopigment bleaching; +// - Stockman et al. (JOV 2018, 18(6):12): appendix gives +// p = I / (I + I0), I0 = 10^4.3 Td, citing Rushton & Henry (1968); +// - CVRL "Bleaching": +// http://www.cvrl.org/database/text/intros/introbleaches.htm +// Physiological bleaching still belongs after the adapted background is +// defined and before postreceptoral opponent encoding, pooled gain, and +// device-hull mapping. +// +// 3) Background-normalized opponent drive — research plan beyond adaptive MB. +// Convert cone-domain responses into ACC/DKL-style opponent coordinates +// using a background-referenced weighted-LMS achromatic axis. MacLeod- +// Boynton describes chromaticity on an equal-luminance plane, whereas ACC / +// DKL are opponent combinations of cone increments around a background. MB +// therefore carries hue/device geometry and achromatic Yf bookkeeping here; +// ACC/DKL remains the planned space for postreceptoral response and gain. +// +// 4) Saturating contrast response — current rolloff is an engineering curve. +// A future receptor/early-cortical stage may use a Michaelis-Menten or +// Naka-Rushton-like nonlinearity. Some cortical fits may need a +// supersaturating variant. +// Reference: Peirce (JOV 2007, doi:10.1167/7.6.13). +// +// 5) ON/OFF separation — research constraint, not an explicit Test25 split. +// Split increments and decrements around the adapted/background state with +// half-wave rectification before pooled gain. The split is around +// adaptation, not diffuse white. Modern retina work also shows that ON/OFF +// nonlinearities can cancel in natural images, producing a more linear +// effective response than a single static saturating curve suggests. ON/OFF +// therefore constrains the neutral and OFF-side slope; it does not require a +// hard branch in the default curve. +// References: Schiller (1992); Yu, Turner, Baudin & Rieke, +// eLife 2022, 11:e70611, doi:10.7554/eLife.70611. +// +// 6) Pooled cortical gain — research plan, not implemented. +// A full observer stage still requires background-referenced opponency, +// ON/OFF separation, and fitted divisive gain parameters. +// References: Heeger (1992); Carandini & Heeger (2012); Bun & Horwitz +// (2023); Li et al. (2022). +// +// 7) Unified device-hull tonemapping and gamut mapping — active design plan. +// Map the observer-domain result into the display hull while retaining the +// most plausible achromatic and opponent contrast structure the device can +// represent. Diffuse/reference white, adapted neutral, and display peak are +// distinct anchors. ITU-R BT.2408's HDR Reference White framing is the +// practical video reference for keeping diffuse white below specular/display +// peak. +// +// Full normalized BT.709 hull: +// - peak 1.0 and BT.709 constraints together define 0 <= R,G,B <= 1; +// - this is one RGB cube, not a per-channel-to-white operation followed by a +// separate gamut constraint; +// - Test25 runtime units generalize the upper planes to `peak_value`, so the +// equivalent hull is 0 <= R,G,B <= peak_value in the selected target RGB +// basis; +// - the primary triangle is only the chromaticity-plane projection of part +// of this geometry. It does not describe upper faces or complete +// constant-scale cross-sections of the cube; +// - lower and upper channel faces, cube edges/corners, and relevant LMS +// bounds must be considered inside each cone-axis sextant; +// - BT.709 is the primary normalized design target. BT.2020 is a generalized +// target-mode extension, not a reason to weaken the BT.709 formulation. +// +// Sextant constraint: +// - isolated L/M/S displacement axes and their antipodes establish the six +// sections independently of any white rolloff or RGB target; +// - per-cone compression may supply one candidate interior hue objective, +// but it is not required to discover the sections and is not the hull; +// - the final solve must examine the complete target cross-section within +// the active sextant and LMS bounds, rather than assuming radial motion to +// adapted neutral is always optimal. +// +// Device-hull inference: +// - many display hulls can produce more total achromatic output by combining +// primaries than at the same level with a high-purity excursion; +// - an out-of-hull observer response may therefore trade chromatic shape +// toward the achromatic axis when the complete hull demands it; +// - the preferred result is not blind clipping to white, but the face, edge, +// corner, or interior point that best preserves observer-domain contrast +// structure; +// - white is one valid destination when bleaching or an achromatic optimum +// dominates, not the mandatory destination of gamut compression. +// +// Engineering direction inferred from the sources above: +// - use weighted LMS / MB to carry achromatic Yf and cone-axis geometry; +// - use an opponent representation to judge postreceptoral contrast; +// - construct and solve the full display hull in that combined state rather +// than first collapsing channels toward white and then clipping in RGB. +// +// Coupling constraint: +// - hue, tone, and device-hull compression are not independent steps; +// - a hue change after hull compression can push the result out of hull; +// - hue-preserving motion must be solved inside the hull projection or be +// followed by explicit in-hull reprojection; +// - the current complete-cube ray support proves containment with one scalar +// shoulder, but it is a partial implementation of the full sectional +// optimization rather than proof that its one authored direction is the +// globally preferred observer-domain trade. +// Reference direction: MacLeod-Boynton/CIE 170-2 geometry, repository +// weighted-LMS/MB transforms, and the device-hull notes above. +// +// 7a) Optional hue objective inside the device-hull solve — research plan. +// If display compression bends hue incorrectly, the solve may preserve an +// "equivalent Gaussian peak" proxy rather than a raw opponent angle. At +// short and medium wavelengths, perceived hue can behave more like a +// constant spectral peak of an equivalent Gaussian than a constant cone +// ratio as purity changes. +// Practical form: +// - offline, map weighted-LMS/MB chromaticities to an equivalent-Gaussian +// peak parameter mu_eq using a spectral forward model; +// - online, preserve mu_eq inside device-hull mapping while carrying Yf +// separately; +// - do not apply an unconstrained post-hoc hue shift after containment. +// This is an optional hull objective, not a chronological eye stage. +// References: Mizokami et al. (JOV 2006, doi:10.1167/6.9.12); +// O'Neil et al. (JOSAA 2012, doi:10.1364/JOSAA.29.00A165). +// +// 7b) Smooth auto-compression heuristic — currently implemented per cone. +// `compression == 0` derives h from the simultaneous-range reference above: +// one side around adaptation = reference_range_log10 / 2 +// h = (reference_range_log10 / 2) / log10(peak / anchor_out) +// pow(anchor_out / peak, h) = pow(10, -(reference_range_log10 / 2)) +// S_shadow = contrast / (1 - pow(anchor_out / peak, h)) +// The OFF/shadow slope error is derived from the selected reference range. +// Manual positive compression values remain exact. References: Kunkel & +// Reinhard, APGV 2010, doi:10.1145/1836248.1836251; Jiang & Fairchild, +// JIST 2021, doi:10.2352/J.ImagingSci.Technol.2021.65.5.050401. +// +// Current Test25 implementation map: +// ```mermaid +// flowchart LR +// rgb["Scene-linear BT.709"] --> lms["Stockman/CVRL LMS"] +// lms --> grade["Scalar-Yf highlights/shadows"] +// grade --> purity["Adaptive-MB purity"] +// purity --> contrast["Anchor-matched per-cone contrast"] +// contrast --> branch{"Gamut compression enabled?"} +// branch -->|No| rolloff["Retained per-cone LMS shoulder"] +// rolloff --> fallback["Numerical hue-graph solve"] +// branch -->|Yes| authored["Graph-solved trajectory direction"] +// authored --> direction["Continuous source-direction recovery"] +// direction --> planes["Physical radius + selected target planes"] +// planes --> scalar["One scalar shoulder over directional Yf support"] +// fallback --> output["BT.709-linear result"] +// scalar --> output +// ``` +// +// Research roadmap and source-state map: +// ```mermaid +// flowchart TB +// subgraph inputs["Raw inputs / assumptions"] +// rgb2["Scene-linear RGB"] +// colorimetry["Input RGB basis / white / RGB-to-LMS"] +// absolute["Absolute scene scale / retinal context"] +// background["Adaptation drivers / local background"] +// scene_range["Late image context / range"] +// observer["Stockman/CVRL observer assumptions"] +// display["Display primaries / white / peak / black / full hull"] +// end +// subgraph observer_flow["Observer roadmap"] +// receptor["Receptor LMS"] +// adapt["Adapted background reference"] +// cone_contrast["Per-cone background-relative response"] +// bleaching["Bleaching availability"] +// noise["Dim cone-noise visibility floor"] +// opponent["Opponent / achromatic response"] +// onoff["ON / OFF response"] +// gain["Pooled divisive normalization"] +// observer_out["Observer-domain response"] +// end +// subgraph device_map["Joint device-hull mapping"] +// hue_objective["Hue objective: MB / ACC / mu_eq"] +// sextants["Cone-axis sextants + LMS bounds"] +// cube["Full target RGB cube cross-sections"] +// hull_solve["Joint tone / hue / gamut solve"] +// hull_output["Display-hull output"] +// end +// rgb2 --> receptor +// colorimetry --> receptor +// observer --> receptor +// absolute --> receptor +// receptor --> adapt +// background --> adapt +// receptor --> cone_contrast +// adapt --> cone_contrast +// cone_contrast --> bleaching --> noise --> opponent --> onoff --> gain +// scene_range --> gain +// gain --> observer_out +// observer_out --> hue_objective +// observer_out --> hull_solve +// hue_objective --> hull_solve +// sextants --> hull_solve +// display --> cube --> hull_solve --> hull_output +// ``` +// +// Implementation scope: +// - The caller supplies the adapted source state and desired output background +// state. Neutral defaults are 0.18/0.18, so ordinary non-adapting content is +// not moved by the anchors. +// - The receptor basis is an average-observer, mainly foveal Stockman/CVRL +// basis with standard prereceptoral filtering folded into its functions. It +// is not a personalized observer model. +// - Scalar defaults are normalized rendering controls, not fitted +// physiological constants. +// - Conceptually, observer response and device mapping remain distinct. The +// current `psycho25_CompressTargetHull` combines authored hue, selected +// target-plane support, and scalar compression because they must remain +// coupled in practice. +// - Reference and Reduced Max-White derive a bounded direction-support scale +// from the pre-contrast source as independent cone shoulders approach LMS +// white. For source radius r_s and selected-target lower-plane support R_s: +// q_s = rho R_s r_s / sqrt(r_s^2 + R_s^2), +// with rho = 0.8 for Reference and 1 for Reduced Max-White. A quadratic +// collapse weight turns direction continuously toward the source as the +// physical authored radius vanishes. The output radius remains the physical +// radius, so chromatic highlights can still converge on white. The ordinary +// target solve supplies lower-plane correction and max-channel upper-plane +// support. No hue-sector branch, source gamut, output channel clamp, active +// limiting-face branch, retained radius, or segmented Yf range is introduced. + +// Public API contract: +// - `bt709_linear_input` is always scene/display-linear BT.709 RGB. Target +// gamut mode does not change this input conversion. +// - The return value is also represented as linear BT.709 RGB. A BT.2020 +// target may require negative BT.709 components; callers must convert to the +// target RGB space before applying target-space channel limits. +// - `peak_value` is the upper RGB-channel plane in units relative to the +// caller's reference white. A 100-nit peak / 100-nit reference-white test +// therefore uses 1. Runtime target containment is +// 0 <= target RGB <= peak_value. The caller must provide a positive peak +// whose D65 LMS and Yf values are strictly above the output/background +// anchor; invalid display configurations are not clamped or repaired. +// - `gamut_compression_mode`: 0 = BT.709 target, 1 = BT.2020 target. +// - Solved hue evaluation always carries the measured adaptive-MB radius. +// No source gamut is declared, inferred, or used as a normalization bound. +// - Hue authoring defaults to the numerical graph solve. `hue_method` selects +// the Fast60 comparison path, which uses the normalized 50% adaptive-MB +// midpoint and skips peak search plus inverse graph solving. +// - `hull_method` defaults to the reference-scale path, whose source-direction +// recovery uses 80% of its bounded target-relative support as the physical +// radius approaches white. Reduced Max-White raises that direction-support +// factor to 100%; neither mode retains a radius floor. Linear MB Pullback +// instead preserves the +// authored adaptive-MB direction and pulls its radius straight back to the +// first selected-target lower plane, with no lower-plane shoulder or custom +// radius construction. Target RGB Clip bypasses target-hull mapping and +// directly clamps the result in the selected linear BT.709 or BT.2020 RGB +// cube. +// - `hull_method == PSYCHO25_HULL_METHOD_CANONICAL_CYLINDER` selects the +// experimental canonical-cylinder map. It treats the authored adaptive-MB +// trajectory as the preferred point, computes exact selected-target radial +// support at its hue/Yf, forms q = rho/rho_max, leaves q <= 1 unchanged, and +// redirects q > 1 both inward and upward toward target peak D65 white. The +// four `canonical_pressure_*` controls match the interactive experiment: +// pivot = excess-occupancy scale, contrast = pressure exponent, h = bounded +// generalized-Neutwo shoulder, trade = 0 inward-first to 1 upward-first. +// The experiment is defined only for full lower+upper cube enforcement; +// partial plane modes remain on their existing diagnostic paths. +// - `post_compression_mode` selects an independent experiment. Modes Direct +// through Source MB Soft branch from the common post-contrast LMS signal and +// bypass physical per-cone output compression, Graph/Fast60 hue authoring, +// and coupled target-hull mapping. Direct applies no device constraint. +// Per-Channel and Max-Channel apply one +// selected-target RGB shoulder. Adaptive MB Hard, Adaptive MB Soft, and +// Fixed D65 Soft first apply their named lower-plane mapper and then the +// max-channel shoulder. Source MB variants first restore the pre-contrast +// adaptive-MB direction while retaining post-contrast radius and carried +// coordinate. Source BT709 Residual retains the normal coupled Reference +// result's relative luminance and linear-BT.709 residual magnitude, replaces +// only that residual direction with the source direction, and shortens it +// uniformly when selected-target containment requires it. The compatibility +// default is None. +// PsychoV17 Gamut + Neutwo Max retains the physical/hue direction but derives +// scalar magnitude from the common unbounded post-contrast signal after the +// same primary map. One anchor-normalized Neutwo shoulder is its peak map. +// - `PSYCHO25_POST_COMPRESSION_ADAPTIVE_CONTRAST_FIT` keeps Test25's completed +// physical/MIDPOINT result as the ideal point, fits it to the exact enabled +// selected-target six-plane support at the same adaptive-MB hue/Yf, then +// measures only bounded adaptation-relative Yf contrast lost by that fit. +// Lost chromatic radius is weighted by position above adapted Yf, genuine +// lost achromatic Yf is added independently, and their bounded pressure +// advances one later Test25 per-cone/MIDPOINT state before exact refitting. +// No straight RGB-to-white interpolation is used; near black chroma loss +// alone produces no trajectory advance. +// - `upper_hull_pivot` defaults to the existing black-origin constant-ratio +// peak ray. The experimental adapted-output mode instead applies the peak +// shoulder to target-channel headroom measured from `anchor_out`, keeping +// that adapted output/background state as the exact geometric pivot. +// - `compression`: positive = manual shoulder h; 0 = automatic h derived from +// the centered simultaneous-range reference. Manual h parameterizes both +// the no-gamut per-cone fallback and target-plane trajectory guide. Automatic +// h is resolved against each path's respective peak. Whenever any target +// plane is enabled, the direction guide uses a neutral endpoint of +// `target_peak_yf * guidance_peak_scale`; the real target peak remains +// unchanged for physical magnitude, radius, and upper-plane containment. +// - `guidance_peak_scale`: target-relative neutral Yf endpoint multiplier for +// target-plane hue guidance. It defaults to 1, is clamped to at least 1, +// and is ignored when no target planes are active. At 1x the guide is the +// regular physical per-channel shoulder. +// - `upper_plane_shoulder_power`: positive = independent upper-plane scalar +// shoulder h; 0 = match the resolved `compression` h. It has no effect when +// target peak/upper-plane enforcement is disabled. +// - `gamut_compression`: <= epsilon selects the retained per-cone LMS fallback; +// > epsilon selects both target-plane classes under legacy enforcement. +// Intermediate strength values are intentionally not a blend between two +// compressors. +// - `gamut_enforcement` independently selects target primary/lower-plane and +// target peak/upper-plane enforcement. With peak enforcement disabled, the +// gamut branch retains the authored Yf instead of imposing an RGB-channel +// peak. The legacy default follows `gamut_compression`: disabled maps to no +// target planes and enabled maps to both plane classes. +// - `cone_response_exponent` remains the response multiplier over the direct +// adapted-LMS contrast and purity controls. `encoded_response_power` is an +// adapted-anchor-preserving power in the compression-encoded response +// domain. +// - `input_pre_step` optionally retains signed LMS, clamps to positive LMS, +// clips to CIE 170-2, or aligns the signed MB hue ray to CIE 170-2 while +// retaining absolute Yf. +// - `observer_gamut_mode` is independent of `input_pre_step` and selected +// target gamut. CIE 170-2 mode constrains actual LMS immediately after +// per-cone contrast, before the physical/guidance shoulders and hue graph. +// It projects to the exact CIE 170-2 MacLeod-Boynton boundary along the +// fixed D65-relative hue ray while carrying nonnegative weighted L+M. The +// graph applies the same constraint to each candidate contrast response. +// None is the compatibility default. +// - `clip_point`, `hue_restore`, `white_curve_mode`, `adaptive_normalization`, +// `bleaching_intensity`, `highlight_saturation`, and `gamut_hue_restore` +// are retained for source compatibility but ignored. +float3 psychotm_test25( + float3 bt709_linear_input, // linear BT.709 RGB + float peak_value = 1000.f / 203.f, // target RGB upper plane + float exposure = 1.f, // linear scaling + float highlights = 1.f, // scalar-Yf highlight grade + float shadows = 1.f, // scalar-Yf shadow grade + float contrast = 1.f, // anchor-matched contrast + float purity_scale = 1.f, // adaptive-MB purity/contrast + float bleaching_intensity = 1.f, // ignored + float clip_point = 100.f, // ignored + float hue_restore = 1.f, // ignored + float encoded_response_power = 1.f, // encoded-domain power + int white_curve_mode = 0, // ignored + float cone_response_exponent = 1.f, // contrast/purity response + float3 current_adaptive_state_bt709 = 0.18f, // input/adaptation anchor + float3 current_background_state_bt709 = 0.18f, // output/background anchor + float gamut_compression = 1.f, // 0 per-cone; >0 legacy full hull + int gamut_compression_mode = 1, // target: BT.709/BT.2020 + float adaptive_normalization = 1.f, // ignored + float compression = 0.f, // shoulder h; 0 = auto + float highlight_saturation = 1.f, // ignored + float gamut_hue_restore = 0.f, // ignored + int hue_method = PSYCHO25_HUE_METHOD_GRAPH, + int hull_method = PSYCHO25_HULL_METHOD_REFERENCE_SCALE, + int gamut_enforcement = PSYCHO25_GAMUT_ENFORCEMENT_LEGACY, + int upper_hull_pivot = PSYCHO25_UPPER_HULL_PIVOT_BLACK, + float upper_plane_shoulder_power = PSYCHO25_UPPER_PLANE_SHOULDER_POWER_MATCH_COMPRESSION, + float guidance_peak_scale = PSYCHO25_DEFAULT_GUIDANCE_PEAK_SCALE, + int input_pre_step = PSYCHO25_INPUT_PRESTEP_NONE, + int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE, + int post_compression_mode = PSYCHO25_POST_COMPRESSION_NONE, + float canonical_pressure_pivot = PSYCHO25_CANONICAL_CYLINDER_DEFAULT_PIVOT, + float canonical_pressure_contrast = PSYCHO25_CANONICAL_CYLINDER_DEFAULT_CONTRAST, + float canonical_pressure_h = PSYCHO25_CANONICAL_CYLINDER_DEFAULT_H, + float canonical_pressure_trade = PSYCHO25_CANONICAL_CYLINDER_DEFAULT_TRADE, + float canonical_yf_bias_power = PSYCHO25_CANONICAL_YF_CONE_DEFAULT_BIAS_POWER) { + float response_scale = cone_response_exponent; + contrast *= response_scale; + purity_scale *= response_scale; + float safe_encoded_response_power = encoded_response_power; + + // The synthetic EXR stress chart contains binary16 infinities. Letting those + // enter the LMS matrices creates NaNs, which bypass gamut/peak comparisons + // and are later displayed at the presenter's safety clamp. Preserve their + // signs at the largest finite binary16 value; map undefined NaNs to black. + float3 exposed_bt709 = bt709_linear_input * exposure; + float3 finite_bt709_input = renodx::math::ZeroNaN(exposed_bt709); + finite_bt709_input = renodx::math::Select( + isinf(finite_bt709_input), + renodx::math::CopySign(65504.f.xxx, finite_bt709_input), + finite_bt709_input); + float3 lms_in = + renodx::color::lms::from::BT709(finite_bt709_input); + lms_in = psycho25_ApplyInputPreStep(lms_in, input_pre_step); + float3 target_lms_peak = + renodx::color::lms::from::BT709(float(peak_value).xxx); + float3 current_adaptive_state_lms = + renodx::color::lms::from::BT709(current_adaptive_state_bt709); + float3 desired_background_state_lms = + renodx::color::lms::from::BT709(current_background_state_bt709); + + // ------------------------------------------------------------------------- + // Anchor-matched adapted-D65 response. + // input == anchor_in maps to anchor_out for any compression setting. + // Test25 accepts these states from the caller; it does not estimate retinal + // adaptation or bleaching internally. + // ------------------------------------------------------------------------- + float3 anchor_in = current_adaptive_state_lms; + float3 anchor_out = desired_background_state_lms; + float contrast_power = contrast; + + // ------------------------------------------------------------------------- + // Achromatic highlight/shadow controls. + // The ONOFF source is luminance-only. Evaluating the grading curves once on + // Yf and applying a scalar gain to the complete LMS vector avoids an + // unsupported independent L/M/S grade and its resulting hue rotation. + // Cone signs are retained through authored hue and target containment. + // ------------------------------------------------------------------------- + float3 graded_lms = abs(lms_in); + float graded_yf = psycho25_YfFromLMS(graded_lms); + float adapted_anchor_yf = psycho25_YfFromLMS(anchor_in); + float graded_yf_out = psycho25_HighlightsScalarV4( + graded_yf, + highlights, + adapted_anchor_yf); + graded_yf_out = psycho25_ShadowsScalarV4( + graded_yf_out, + shadows, + adapted_anchor_yf); + graded_lms *= renodx::math::DivideSafe( + graded_yf_out, + graded_yf, + 1.f); + graded_lms = renodx::math::CopySign(graded_lms, lms_in); + + // ------------------------------------------------------------------------- + // Purity delta in adaptive MB: + // purity_delta = purity / contrast + // contrast == purity: no purity change. + // purity > contrast: increase radius from adapted neutral. + // purity < contrast: reduce radius toward adapted neutral. + // ------------------------------------------------------------------------- + float purity_delta = renodx::math::DivideSafe(purity_scale, contrast_power, 1.f); + float3 contrast_input = psycho25_ApplyAdaptiveMBPurity( + graded_lms, + anchor_in, + purity_delta); + + // ------------------------------------------------------------------------- + // Anchor-matched contrast remains explicit before display compression so + // source adaptive-MB direction/radius and the current rolloff-derived hue + // field can be evaluated separately. The optional observer-gamut stage is + // applied here, after contrast rather than as an input pre-step, and to the + // corresponding post-contrast state of every numerical hue-graph candidate. + // ------------------------------------------------------------------------- + float3 contrast_lms = psycho25_ApplyContrastResponse( + contrast_input, + anchor_in, + anchor_out, + contrast_power, + observer_gamut_mode); + + // ------------------------------------------------------------------------- + // Display-compression shoulder parameter. + // Positive `compression` is manual h; zero selects the centered-range auto + // value. The helpers implement the slope-normalized formula documented + // above. This resolved h parameterizes the no-gamut per-cone fallback and + // the real-peak magnitude in target-plane mode. An automatic direction guide + // resolves h again against its target-relative guidance endpoint; a positive + // manual h remains shared. The upper-plane scalar shoulder matches the real-peak h by + // default but can use its own positive h for diagnosis. + // Its slope-normalized power first encodes an adapted cone-response state. + // Sign-preserving encoded-response power is applied in that domain before + // the rational shoulder generates the channel scale. Hue authoring carries + // the measured adaptive-MB radius in both comparison modes. + // ------------------------------------------------------------------------- + float target_compression_power = compression; + if (compression == PSYCHO25_AUTO_COMPRESSION_SENTINEL) { + target_compression_power = + psycho25_AutoCompressionFromCenteredReferenceRange( + psycho25_YfFromLMS(anchor_out), + psycho25_YfFromLMS(target_lms_peak)); + } + target_compression_power = max( + target_compression_power, + PSYCHO25_MIN_MANUAL_COMPRESSION); + float resolved_upper_plane_shoulder_power = upper_plane_shoulder_power; + if (upper_plane_shoulder_power + == PSYCHO25_UPPER_PLANE_SHOULDER_POWER_MATCH_COMPRESSION) { + resolved_upper_plane_shoulder_power = target_compression_power; + } + resolved_upper_plane_shoulder_power = max( + resolved_upper_plane_shoulder_power, + PSYCHO25_MIN_MANUAL_COMPRESSION); + + // ------------------------------------------------------------------------- + // Coupled authored-hue and device-hull stage. With any target plane active, + // the per-cone guide uses a target-relative neutral Yf endpoint. At the 1x + // default this is the same endpoint as regular per-channel compression. The + // configured target peak remains the + // actual upper cube plane. Numerical mode solves the hue graph; Fast60 uses + // its direct angular midpoint with the source. Both retain the actual-peak + // physical radius and discard carried scale before target support is solved. + // Enabled target lower planes constrain adaptive-MB chrominance. Enabled + // upper planes define one directional Yf limit, and one scalar shoulder maps + // into it. This remains a single hull-ray solve, not the planned sectional + // optimization over multiple candidate points. + // ------------------------------------------------------------------------- + int normalized_target_gamut_mode = gamut_compression_mode == 0 ? 0 : 1; + const bool use_psychov17_gamut = post_compression_mode + == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT + || post_compression_mode + == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NEUTWO_MAX + || post_compression_mode + == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NRG_WHITE; + const bool use_psychov17_neutwo_peak = post_compression_mode + == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NEUTWO_MAX; + const bool use_psychov17_nrg_white = post_compression_mode + == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NRG_WHITE; + const bool use_adaptive_contrast_fit = post_compression_mode + == PSYCHO25_POST_COMPRESSION_ADAPTIVE_CONTRAST_FIT; + int resolved_gamut_enforcement = gamut_enforcement < 0 + ? (gamut_compression <= PSYCHO25_EPSILON + ? PSYCHO25_GAMUT_ENFORCEMENT_NONE + : PSYCHO25_GAMUT_ENFORCEMENT_FULL) + : gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_FULL; + Psycho25ConeResponseParameters target_cone_response = + psycho25_PrepareConeResponseParameters( + anchor_out, + target_lms_peak, + contrast_power, + target_compression_power, + safe_encoded_response_power); + float3 signed_direction_source_lms = contrast_input; + float3 output_lms; + if (post_compression_mode >= PSYCHO25_POST_COMPRESSION_DIRECT + && post_compression_mode <= PSYCHO25_POST_COMPRESSION_SOURCE_MB_SOFT_MAX + && resolved_gamut_enforcement != PSYCHO25_GAMUT_ENFORCEMENT_NONE) { + // Post experiments deliberately branch before every physical per-cone, + // Graph/Fast60, and coupled-hull output operation. The optional observer + // constraint remains an independent earlier stage through contrast_lms. + output_lms = psycho25_ApplyIndependentPostCompression( + contrast_lms, + signed_direction_source_lms, + anchor_out, + current_adaptive_state_lms, + peak_value, + target_compression_power, + normalized_target_gamut_mode, + resolved_gamut_enforcement, + post_compression_mode); + } else if (resolved_gamut_enforcement + == PSYCHO25_GAMUT_ENFORCEMENT_NONE + || use_psychov17_gamut + || use_adaptive_contrast_fit) { + // No-gamut mode retains the direct per-channel LMS compressor. The + // PsychoV17 option deliberately starts from this same complete Test25 + // physical/hue result before its separate final primary-gamut map. + output_lms = psycho25_ApplyPhysicalPerConePath( + contrast_lms, + signed_direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + contrast_power, + target_cone_response, + hue_method, + observer_gamut_mode); + } else if (hull_method == PSYCHO25_HULL_METHOD_TARGET_RGB_CLIP) { + // Clip remains the literal component-clamp comparison applied to the + // ordinary physical/Graph result. It is intentionally distinct from the + // independent post-contrast experiments above. + output_lms = psycho25_ApplyPhysicalPerConePath( + contrast_lms, + signed_direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + contrast_power, + target_cone_response, + hue_method, + observer_gamut_mode); + float3 post_target_rgb = psycho25_TargetRGBFromLMS( + output_lms, + normalized_target_gamut_mode); + if ((resolved_gamut_enforcement + & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0) { + post_target_rgb = max(post_target_rgb, 0.f.xxx); + } + if ((resolved_gamut_enforcement + & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0) { + post_target_rgb = min(post_target_rgb, peak_value.xxx); + } + output_lms = psycho25_LMSFromTargetRGB( + post_target_rgb, + normalized_target_gamut_mode); + } else { + // Target-plane mode uses the requested primary and/or peak constraints. + // The per-channel curve remains the direct output compressor only in the + // disabled branch above. Here the complete no-gamut result supplies only + // the adaptive-MB trajectory; its scale is discarded before target-plane + // correction, so it is not a second output curve. Guidance direction and + // its cone-response state remain separate from physical target magnitude. + float target_peak_yf = psycho25_SignedYfFromLMS(target_lms_peak); + float resolved_guidance_peak_yf = psycho25_ResolveGuidancePeakYf( + target_peak_yf, + guidance_peak_scale); + float3 guidance_lms_peak = + target_lms_peak * (resolved_guidance_peak_yf / target_peak_yf); + float guidance_compression_power = target_compression_power; + if (compression == PSYCHO25_AUTO_COMPRESSION_SENTINEL) { + guidance_compression_power = + psycho25_AutoCompressionFromCenteredReferenceRange( + psycho25_YfFromLMS(anchor_out), + resolved_guidance_peak_yf); + } + Psycho25ConeResponseParameters guidance_cone_response = + psycho25_PrepareConeResponseParameters( + anchor_out, + guidance_lms_peak, + contrast_power, + guidance_compression_power, + safe_encoded_response_power); + output_lms = psycho25_CompressTargetHull( + contrast_lms, + signed_direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + guidance_lms_peak, + contrast_power, + resolved_upper_plane_shoulder_power, + target_cone_response, + guidance_cone_response, + peak_value, + normalized_target_gamut_mode, + resolved_gamut_enforcement, + hue_method, + hull_method, + upper_hull_pivot, + canonical_pressure_pivot, + canonical_pressure_contrast, + canonical_pressure_h, + canonical_pressure_trade, + canonical_yf_bias_power, + observer_gamut_mode); + } + + if (post_compression_mode + == PSYCHO25_POST_COMPRESSION_SOURCE_BT709_RESIDUAL) { + output_lms = psycho25_RestoreSourceBT709ResidualDirection( + output_lms, + signed_direction_source_lms, + peak_value, + normalized_target_gamut_mode, + resolved_gamut_enforcement); + } else if (use_adaptive_contrast_fit) { + output_lms = psycho25_ApplyAdaptiveContrastFit( + output_lms, + contrast_lms, + signed_direction_source_lms, + current_adaptive_state_lms, + anchor_in, + anchor_out, + target_lms_peak, + contrast_power, + target_cone_response, + hue_method, + peak_value, + normalized_target_gamut_mode, + resolved_gamut_enforcement, + observer_gamut_mode); + } else if (use_psychov17_gamut) { + if (gamut_compression != 0.f) { + // Match PsychoV17's final device map exactly on the completed physical + // output before any experiment-specific peak operation. + output_lms = psycho25_GamutCompressLMSBoundAdaptive( + output_lms, + current_adaptive_state_lms, + normalized_target_gamut_mode, + gamut_compression); + } + if (use_psychov17_neutwo_peak) { + // Retain the physical/Graph trajectory as direction so positive hue rays + // still converge to white. Derive only scalar magnitude from the + // unbounded post-contrast signal after the same PsychoV17 primary map; + // applying Neutwo directly to the already bounded physical magnitude + // would cap neutral at peak/sqrt(2). + float3 target_rgb = psycho25_TargetRGBFromLMS( + output_lms, + normalized_target_gamut_mode); + float3 magnitude_lms = contrast_lms; + if (gamut_compression != 0.f) { + magnitude_lms = psycho25_GamutCompressLMSBoundAdaptive( + magnitude_lms, + current_adaptive_state_lms, + normalized_target_gamut_mode, + gamut_compression); + } + float3 magnitude_target_rgb = psycho25_TargetRGBFromLMS( + magnitude_lms, + normalized_target_gamut_mode); + if ((resolved_gamut_enforcement + & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0) { + target_rgb = max(target_rgb, 0.f.xxx); + magnitude_target_rgb = max(magnitude_target_rgb, 0.f.xxx); + } + float direction_max_channel = renodx::math::Max(abs(target_rgb)); + float magnitude_max_channel = renodx::math::Max( + abs(magnitude_target_rgb)); + float3 anchor_target_rgb = psycho25_TargetRGBFromLMS( + anchor_out, + normalized_target_gamut_mode); + float anchor_max_channel = min( + renodx::math::Max(abs(anchor_target_rgb)), + peak_value - PSYCHO25_EPSILON); + float anchor_input_max = renodx::tonemap::inverse::Neutwo( + anchor_max_channel, + peak_value); + float mapped_max_channel = renodx::tonemap::Neutwo( + magnitude_max_channel * renodx::math::DivideSafe( + anchor_input_max, + anchor_max_channel, + 1.f), + peak_value); + target_rgb *= renodx::math::DivideSafe( + mapped_max_channel, + direction_max_channel, + 1.f); + output_lms = psycho25_LMSFromTargetRGB( + target_rgb, + normalized_target_gamut_mode); + } else if (use_psychov17_nrg_white) { + // Retain the completed output's ACC-A scalar metric while replacing an + // over-peak selected-target RGB point with an in-cube point between its + // max-channel hue wall and peak D65 white. ACC-A here is an engineering + // scalar metric inherited from NRG Test7, not radiometric energy. + float3 target_rgb = max( + psycho25_TargetRGBFromLMS( + output_lms, + normalized_target_gamut_mode), + 0.f.xxx); + float max_target_channel = max( + target_rgb.x, + max(target_rgb.y, target_rgb.z)); + if (max_target_channel > peak_value) { + float3 target_bt2020 = normalized_target_gamut_mode == 0 + ? renodx::color::bt2020::from::BT709(target_rgb) + : target_rgb; + float3 hue_wall_target_rgb = target_rgb + * (peak_value / max_target_channel); + float3 hue_wall_bt2020 = normalized_target_gamut_mode == 0 + ? renodx::color::bt2020::from::BT709(hue_wall_target_rgb) + : hue_wall_target_rgb; + float scalar_output_raw; + target_bt2020 = renodx::tonemap::nrg::NRGTest7SolveWhiteSpillByScalarAccA( + hue_wall_bt2020, + peak_value, + renodx::tonemap::nrg::NRGTest7ScalarAccARaw( + target_bt2020, + peak_value), + scalar_output_raw); + target_rgb = normalized_target_gamut_mode == 0 + ? renodx::color::bt709::from::BT2020(target_bt2020) + : target_bt2020; + } + output_lms = psycho25_LMSFromTargetRGB( + target_rgb, + normalized_target_gamut_mode); + } + } + + return renodx::color::bt709::from::LMS(output_lms); +} + +} // namespace psychov +} // namespace tonemap +} // namespace renodx + +#endif // RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ diff --git a/src/games/deathstranding2/tonemap/tonemap.hlsli b/src/games/deathstranding2/tonemap/tonemap.hlsli index 8dd6baec3..fe8c59aaf 100644 --- a/src/games/deathstranding2/tonemap/tonemap.hlsli +++ b/src/games/deathstranding2/tonemap/tonemap.hlsli @@ -1,25 +1,5 @@ #include "../common.hlsli" - -/// Identity through anchor to every derivative; then approaches peak -/// monotonically and concave down. Requires anchor < peak and compression_strength >= 1. -#define APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(T) \ - T ApplyAnchoredCInfinityShoulder(T color, T peak, T anchor, float compression_strength) { \ - T shoulder_range = peak - anchor; \ - T distance_from_anchor = max(color - anchor, (T)0.f); \ - T flat_weight = exp2(-shoulder_range / (compression_strength * distance_from_anchor)); \ - T response_denominator = mad(distance_from_anchor, flat_weight, shoulder_range); \ - return mad(shoulder_range, distance_from_anchor / response_denominator, color - distance_from_anchor); \ - } - -APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float) -APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float3) -#undef APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR - -float ApplyAnchoredCInfinityShoulderMaxChannelScale(float3 color, float peak, float anchor, float compression_strength) { - float max_channel = renodx::math::Max(abs(color)); - float compressed_max = ApplyAnchoredCInfinityShoulder(max_channel, peak, anchor, compression_strength); - return renodx::math::DivideSafe(compressed_max, max_channel, 1.f); -} +#include "./psychov25/test25.hlsli" float3 ComputeCInfinityTransition(float3 position) { position = saturate(position); @@ -52,7 +32,7 @@ float3 ApplyAnchoredTonalGrading( float3 normalized = ax / anchor_in; float3 contrasted_normalized = normalized; - // power contrast and shadow flare with bounded highlights + // Power contrast and shadow flare, optionally bounding contrast on highlights. [branch] if (contrast != 1.f || flare > 0.f) { float3 exponent = contrast; @@ -64,6 +44,7 @@ float3 ApplyAnchoredTonalGrading( exponent *= mad(flat_shadow_weight, flare / (normalized + flare), 1.f); } +#if 1 float3 input_stops = log2(normalized); float3 highlight_stops = max(input_stops, 0.f); float3 output_highlight_stops = highlight_stops; @@ -76,6 +57,9 @@ float3 ApplyAnchoredTonalGrading( } contrasted_normalized = exp2(mad(exponent, min(input_stops, 0.f), output_highlight_stops)); +#else + contrasted_normalized = pow(normalized, exponent); +#endif } // broad highlight contrast. @@ -96,9 +80,13 @@ float3 ApplyAnchoredTonalGrading( contrasted_normalized *= pow(1.f + flat_shadow_distance, shadow_contrast - 1.f); } - // mirror offsets about the anchor: start at one stop and reach full strength at eight stops + // Mirror offsets about the anchor over the declared stop range. [branch] if (highlights != 1.f || shadows != 1.f) { + static const float TONAL_OFFSET_START_STOPS = 1.f; + static const float TONAL_OFFSET_END_STOPS = 8.f; + static const float TONAL_OFFSET_INVERSE_RANGE_STOPS = 1.f / (TONAL_OFFSET_END_STOPS - TONAL_OFFSET_START_STOPS); + float3 tonal_stops = log2(contrasted_normalized); float3 tonal_displacement = 0.f; @@ -106,7 +94,7 @@ float3 ApplyAnchoredTonalGrading( if (highlights != 1.f) { float highlight_adjustment = highlights - 1.f; float highlight_displacement = highlight_adjustment * mad(1.5f, abs(highlight_adjustment), 0.5f); - float3 highlight_weight = ComputeCInfinityTransition((tonal_stops - 1.f) * 0.125f); + float3 highlight_weight = ComputeCInfinityTransition((tonal_stops - TONAL_OFFSET_START_STOPS) * TONAL_OFFSET_INVERSE_RANGE_STOPS); tonal_displacement = mad(highlight_displacement, highlight_weight, tonal_displacement); } @@ -114,7 +102,7 @@ float3 ApplyAnchoredTonalGrading( if (shadows != 1.f) { float shadow_adjustment = shadows - 1.f; float shadow_displacement = shadow_adjustment * mad(1.5f, abs(shadow_adjustment), 0.5f); - float3 shadow_weight = ComputeCInfinityTransition((-1.f - tonal_stops) * 0.125f); + float3 shadow_weight = ComputeCInfinityTransition((-TONAL_OFFSET_START_STOPS - tonal_stops) * TONAL_OFFSET_INVERSE_RANGE_STOPS); tonal_displacement = mad(shadow_displacement, shadow_weight, tonal_displacement); } @@ -124,6 +112,398 @@ float3 ApplyAnchoredTonalGrading( return renodx::math::CopySign(contrasted_normalized * anchor_out, color); } +/// Identity through anchor to every derivative; then approaches peak +/// monotonically and concave down. Requires anchor < peak and compression_strength >= 1. +#define APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(T) \ + T ApplyAnchoredCInfinityShoulder(T color, T peak, T anchor, float compression_strength) { \ + T shoulder_range = peak - anchor; \ + T distance_from_anchor = max(color - anchor, (T)0.f); \ + T flat_weight = exp2(-shoulder_range / (compression_strength * distance_from_anchor)); \ + T response_denominator = mad(distance_from_anchor, flat_weight, shoulder_range); \ + return mad(shoulder_range, distance_from_anchor / response_denominator, color - distance_from_anchor); \ + } + +APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float) +APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float3) +#undef APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR + +float ApplyAnchoredCInfinityShoulderMaxChannelScale(float3 color, float peak, float anchor, float compression_strength) { + float max_channel = renodx::math::Max(abs(color)); + float compressed_max = ApplyAnchoredCInfinityShoulder(max_channel, peak, anchor, compression_strength); + return renodx::math::DivideSafe(compressed_max, max_channel, 1.f); +} + +/// Identity at and below anchor; C-infinity generalized Naka-Rushton above it. +/// Requires anchor < peak, compression_power > 1, and 0 < response_coefficient <= 1. +#define APPLY_CINFINITY_NAKA_RUSHTON_GENERATOR(T) \ + T ApplyCInfinityNakaRushton(T color, T peak, T anchor, float compression_power = 1.f, float response_coefficient = 0.001f) { \ + float inverse_compression_power = rcp(compression_power); \ + float flat_response_numerator = -1.f / log(2.f) * response_coefficient; \ + T shoulder_range = peak - anchor; \ + T distance_from_anchor = max(color - anchor, (T)0.f); \ + T position = distance_from_anchor / shoulder_range; \ + T position_power = pow(position, compression_power); \ + T flat_response = exp2(flat_response_numerator * rcp(mad(position_power, position_power, position_power))); \ + T response_scale = pow(mad(position_power, flat_response, (T)1.f), -inverse_compression_power); \ + return mad(distance_from_anchor, response_scale, color - distance_from_anchor); \ + } +APPLY_CINFINITY_NAKA_RUSHTON_GENERATOR(float) +APPLY_CINFINITY_NAKA_RUSHTON_GENERATOR(float3) +#undef APPLY_CINFINITY_NAKA_RUSHTON_GENERATOR + +// Fixed PsychoV25 target-hull path: Fast60 hue guidance, Reference Scale, +// full BT.2020 lower/upper-plane enforcement, and a black upper-hull pivot. +float3 CompressPsychoV25ReferenceScaleHull( + float3 desired_lms, + float3 direction_source_lms, + float3 adaptive_state_lms, + float3 background_state_lms, + float3 target_lms_peak, + float source_direction_recovery_strength, + float naka_rushton_compression, + float cinfinity_shoulder_compression, + int white_curve_mode, + float cone_response_exponent, + float peak_value) { + float3 desired_weighted_lms = renodx::color::macleod_boynton::WeighLMS(desired_lms); + float desired_yf = desired_weighted_lms.x + desired_weighted_lms.y; + if (desired_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { + return 0.f.xxx; + } + + float adaptive_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(adaptive_state_lms); + float background_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(background_state_lms); + float target_peak_yf = renodx::tonemap::psychov::psycho25_SignedYfFromLMS(target_lms_peak); + float3 physical_compressed_lms; + [branch] + if (white_curve_mode == 1) { + renodx::tonemap::psychov::Psycho25ConeResponseParameters cone_response = + renodx::tonemap::psychov::psycho25_PrepareConeResponseParameters( + background_state_lms, + target_lms_peak, + cone_response_exponent, + naka_rushton_compression, + 1.f); + renodx::tonemap::psychov::Psycho25ConeResponseState response_state = + renodx::tonemap::psychov::psycho25_BuildConeResponseState( + desired_lms, + cone_response); + float3 normalized_response = response_state.encoded_response + / (abs(response_state.encoded_response) + + response_state.encoded_peak_offset); + float3 compressed_response = cone_response.inverse_compression_power == 1.f + ? normalized_response + : renodx::math::SignPow( + normalized_response, + cone_response.inverse_compression_power); + physical_compressed_lms = target_lms_peak * compressed_response; + } else { + physical_compressed_lms = ApplyAnchoredCInfinityShoulder( + desired_lms, + target_lms_peak, + background_state_lms, + cinfinity_shoulder_compression); + } + float authored_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(physical_compressed_lms); + if (authored_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { + return 0.f.xxx; + } + + float3 safe_adaptive_state_lms = max( + adaptive_state_lms, + renodx::tonemap::psychov::PSYCHO25_EPSILON.xxx); + float2 adapted_neutral_mb = renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 authored_mb = renodx::color::macleod_boynton::from::WeightedLMS( + renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( + physical_compressed_lms, + adaptive_state_lms)); + float3 source_mb = renodx::color::macleod_boynton::from::WeightedLMS( + renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( + direction_source_lms, + adaptive_state_lms)); + + // Fast60: retain physical radius and use the angular midpoint between the + // source direction and the raw per-cone-compressed direction. + float2 authored_offset = authored_mb.xy - adapted_neutral_mb; + float2 source_offset = source_mb.xy - adapted_neutral_mb; + float authored_radius2 = dot(authored_offset, authored_offset); + float source_radius2 = dot(source_offset, source_offset); + if (authored_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON + && source_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON) { + float2 source_direction = source_offset * rsqrt(source_radius2); + float2 compressed_direction = authored_offset * rsqrt(authored_radius2); + float2 output_direction = lerp( + source_direction, + compressed_direction, + 1.f - renodx::tonemap::psychov::PSYCHO25_HUE_AMPLITUDE); + float output_direction2 = dot(output_direction, output_direction); + if (output_direction2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON) { + authored_mb.xy = adapted_neutral_mb + + output_direction * rsqrt(output_direction2) * sqrt(authored_radius2); + authored_offset = authored_mb.xy - adapted_neutral_mb; + authored_radius2 = dot(authored_offset, authored_offset); + } + } + + float authored_radius = sqrt(authored_radius2); + float2 authored_direction = authored_offset * rsqrt(authored_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); + + // Reference Scale source-direction recovery keeps collapsing saturated + // highlights from rotating through an unrelated hue on their way to white. + [branch] + if (source_direction_recovery_strength > 0.f) { + float source_radius = sqrt(source_radius2); + float2 source_direction = source_offset * rsqrt(source_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); + float source_radius_support = + renodx::tonemap::psychov::psycho25_TargetLowerPlaneRadiusForDirection( + source_direction, + adapted_neutral_mb, + adaptive_state_lms, + 1); + float source_direction_support_radius = + renodx::tonemap::psychov::PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY + * source_radius_support + * renodx::math::DivideSafe( + source_radius, + sqrt(source_radius2 + source_radius_support * source_radius_support), + 0.f); + float radius_normalization = max( + max(authored_radius, source_direction_support_radius), + renodx::tonemap::psychov::PSYCHO25_EPSILON); + float authored_weight = pow( + authored_radius / radius_normalization, + renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); + float source_direction_support_weight = pow( + source_direction_support_radius / radius_normalization, + renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); + float source_hue_support = + renodx::tonemap::psychov::PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION + * source_radius_support; + float source_hue_confidence = renodx::math::DivideSafe( + source_radius2, + source_radius2 + source_hue_support * source_hue_support, + 0.f); + float source_collapse_weight = renodx::math::DivideSafe( + source_direction_support_weight, + authored_weight + source_direction_support_weight, + 0.f); + float source_direction_weight = source_direction_recovery_strength + * (1.f - (1.f - source_hue_confidence) * (1.f - source_collapse_weight)); + float2 combined_direction = lerp( + authored_direction, + source_direction, + source_direction_weight); + combined_direction *= rsqrt( + dot(combined_direction, combined_direction) + + renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON); + authored_direction = combined_direction; + authored_offset = authored_direction * authored_radius; + authored_mb.xy = adapted_neutral_mb + authored_offset; + } + + // Discard the trajectory's carried scale, preserving only its authored + // adaptive-MB direction and radius before solving the BT.2020 hull. + float trajectory_yf_for_normalization = authored_mb.z + * (authored_mb.x * safe_adaptive_state_lms.x + + (1.f - authored_mb.x) * safe_adaptive_state_lms.y); + float3 unit_yf_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( + float3( + authored_mb.xy, + renodx::math::DivideSafe( + authored_mb.z, + trajectory_yf_for_normalization, + 0.f)), + adaptive_state_lms); + float3 neutral_lms = adaptive_state_lms / adaptive_yf; + + // Reference Scale lower-plane compression keeps the authored hue ray inside + // the nonnegative BT.2020 primary half-spaces without a component clamp. + if (authored_radius > renodx::tonemap::psychov::PSYCHO25_EPSILON) { + float3 neutral_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(neutral_lms, 1); + float3 current_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); + float current_boundary_fraction = + renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( + current_target_rgb, + neutral_target_rgb); + float current_radius_scale = + renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( + current_boundary_fraction); + + authored_direction = authored_offset / authored_radius; + float containment_reference_radius = max( + authored_radius, + length(source_mb.xy - adapted_neutral_mb)); + float3 reference_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( + float3( + adapted_neutral_mb + + authored_direction * containment_reference_radius, + 1.f), + adaptive_state_lms); + reference_lms /= renodx::tonemap::psychov::psycho25_YfFromLMS(reference_lms); + float3 reference_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(reference_lms, 1); + float reference_boundary_fraction = + renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( + reference_target_rgb, + neutral_target_rgb); + float reference_radius_scale = + renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( + reference_boundary_fraction); + + float trajectory_fraction = authored_radius / containment_reference_radius; + float release_progress = saturate( + trajectory_fraction + / renodx::tonemap::psychov::PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION); + float neutral_scale = min(1.f, 4.f * reference_radius_scale); + float release_weight = 1.f - release_progress; + float radius_scale = min( + lerp( + reference_radius_scale, + neutral_scale, + release_weight * release_weight), + current_radius_scale); + unit_yf_lms = lerp(neutral_lms, unit_yf_lms, radius_scale); + } + + // Black-pivot upper-plane shoulder along the contained BT.2020 hue ray. + float3 unit_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); + float max_target_channel = max( + unit_target_rgb.x, + max(unit_target_rgb.y, unit_target_rgb.z)); + float directional_yf_limit = peak_value / max_target_channel; + float normalized_input = desired_yf * renodx::math::DivideSafe(target_peak_yf, directional_yf_limit, 1.f); + float normalized_output; + [branch] + if (white_curve_mode == 1) { + normalized_output = ApplyCInfinityNakaRushton( + normalized_input, + target_peak_yf, + background_yf, + naka_rushton_compression, + 0.001f); + } else { + normalized_output = ApplyAnchoredCInfinityShoulder( + normalized_input, + target_peak_yf, + background_yf, + cinfinity_shoulder_compression); + } + float output_yf = normalized_output * renodx::math::DivideSafe(directional_yf_limit, target_peak_yf, 1.f); + return unit_yf_lms * output_yf; +} + +float3 ApplyCustomPsychoV25ToneMapWithEOTFEmulation( + float3 bt709_linear_input, + float peak_value, + float highlights, + float shadows, + float cone_response_exponent, + float flare, + float purity_scale, + float highlight_saturation, + float dechroma, + float source_direction_recovery_strength = 0.f, + float3 current_adaptive_state_bt709 = 0.18f, + float3 current_background_state_bt709 = 0.18f, + int white_curve_mode = 0, + float naka_rushton_compression = 0.f, + float cinfinity_shoulder_compression = 1.5f, + bool use_eotf_emulation = false) { + float3 finite_bt709_input = renodx::math::ZeroNaN(bt709_linear_input); + finite_bt709_input = renodx::math::Select( + isinf(finite_bt709_input), + renodx::math::CopySign(65504.f.xxx, finite_bt709_input), + finite_bt709_input); + + float3 lms_in = renodx::color::lms::from::BT709(finite_bt709_input); + float3 current_adaptive_state_lms = + renodx::color::lms::from::BT709(current_adaptive_state_bt709); + float3 current_background_state_lms = + renodx::color::lms::from::BT709(current_background_state_bt709); + float3 target_lms_peak = renodx::color::lms::from::BT709(peak_value.xxx); + + if (dechroma != 0.f || highlight_saturation != 1.f) { + float luminance = renodx::color::yf::from::LMS(lms_in); + float neutral_luminance = renodx::color::yf::from::LMS(current_adaptive_state_lms); + + // Ramp purity grading over 2.75 decades above the adaptive neutral. + static const float INVERSE_HIGHLIGHT_RANGE_STOPS = 1.f / (2.75f * log2(10.f)); + static const float HIGHLIGHT_ROLLOFF_CUBIC_BLEND = 0.5f; + static const float HIGHLIGHT_PURITY_STRENGTH = 2.f / 3.f; + + float luminance_from_neutral = max(luminance, neutral_luminance) / neutral_luminance; + float rolloff_position = saturate(log2(luminance_from_neutral) * INVERSE_HIGHLIGHT_RANGE_STOPS); + float rolloff_position_squared = rolloff_position * rolloff_position; + float rolloff = rolloff_position_squared * rolloff_position * mad(rolloff_position, mad(6.f, rolloff_position, -15.f), 10.f); + + // Base smootherstep brings dechroma into the midtones while remaining monotonic and C2. + if (dechroma != 0.f) { + purity_scale *= mad(-dechroma, rolloff, 1.f); + } + + // Blend smootherstep squared and cubed for a later, gentler C2 progression. + if (highlight_saturation != 1.f) { + float highlight_rolloff = rolloff * rolloff * mad(HIGHLIGHT_ROLLOFF_CUBIC_BLEND, rolloff, 1.f - HIGHLIGHT_ROLLOFF_CUBIC_BLEND); + purity_scale *= mad(highlight_saturation - 1.f, highlight_rolloff * HIGHLIGHT_PURITY_STRENGTH, 1.f); + } + } + + float3 contrast_input = renodx::tonemap::psychov::psycho25_ApplyAdaptiveMBPurity( + lms_in, + current_adaptive_state_lms, + purity_scale); + + float3 contrast_lms = contrast_input; + if (use_eotf_emulation) { + const float3 BT709_WHITE_LMS = renodx::color::lms::from::BT709(1.f); + contrast_lms = renodx::color::correct::GammaSafe(contrast_lms / BT709_WHITE_LMS) * BT709_WHITE_LMS; + } + + contrast_lms = ApplyAnchoredTonalGrading( + contrast_lms, + current_adaptive_state_lms, + current_background_state_lms, + cone_response_exponent, + flare, + 1.f, + 1.f, + highlights, + shadows); + + float naka_rushton_compression_power = naka_rushton_compression; + if (white_curve_mode == 1) { + if (naka_rushton_compression == renodx::tonemap::psychov::PSYCHO25_AUTO_COMPRESSION_SENTINEL) { + naka_rushton_compression_power = renodx::tonemap::psychov::psycho25_AutoCompressionFromCenteredReferenceRange( + renodx::tonemap::psychov::psycho25_YfFromLMS(current_background_state_lms), + renodx::tonemap::psychov::psycho25_YfFromLMS(target_lms_peak)); + } + naka_rushton_compression_power = max( + naka_rushton_compression_power, + renodx::tonemap::psychov::PSYCHO25_MIN_MANUAL_COMPRESSION); + } + + float3 output_lms = CompressPsychoV25ReferenceScaleHull( + contrast_lms, + contrast_input, + current_adaptive_state_lms, + current_background_state_lms, + target_lms_peak, + source_direction_recovery_strength, + naka_rushton_compression_power, + cinfinity_shoulder_compression, + white_curve_mode, + cone_response_exponent, + peak_value); + return renodx::color::bt709::from::LMS(output_lms); +} + float3 ApplyAdaptiveMBPurity(float3 lms_input, float3 adaptive_neutral_lms, float purity_scale) { if (abs(purity_scale - 1.f) <= 1e-5f) return lms_input; @@ -173,22 +553,6 @@ float3 ApplyPurityGradingLMS(float3 color_lms, float purity_scale, float highlig return color_lms; } -float3 ApplyGammaCorrectionForToneMap(float3 color_input) { - float3 color_corrected; - if (RENODX_GAMMA_CORRECTION != 0.f) { - if (RENODX_TONE_MAP_WORKING_COLOR_SPACE == 0.f) { - color_corrected = renodx::color::correct::GammaSafe(color_input); - } else { - const float3 BT709_WHITE_LMS = renodx::color::lms::from::BT709(1.f); - color_corrected = renodx::color::bt709::from::LMS(renodx::color::correct::GammaSafe(renodx::color::lms::from::BT709(color_input) / BT709_WHITE_LMS) * BT709_WHITE_LMS); - } - } else { - color_corrected = color_input; - } - - return color_corrected; -} - float3 SampleGamma2LUT(float3 color_input, Texture3D _29, SamplerState lut_sampler, float _40_m0_10u_z, float _40_m0_10u_w) { @@ -432,10 +796,12 @@ float3 ApplyUserGradingAndToneMapAndScale(float3 untonemapped_bt709, InUniform_Constant_080_z, InUniform_Constant_096_x, InUniform_Constant_096_y, InUniform_Constant_096_z); } else { - untonemapped_bt709 = ApplyGammaCorrectionForToneMap(untonemapped_bt709); - float3 tonemapped_bt709; if (RENODX_TONE_MAP_WORKING_COLOR_SPACE == 0.f) { // BT.709 + if (RENODX_GAMMA_CORRECTION != 0.f) { + untonemapped_bt709 = renodx::color::correct::GammaSafe(untonemapped_bt709); + } + float3 untonemapped_graded_bt709 = ApplyAnchoredTonalGrading( untonemapped_bt709, 0.18f, 0.18f, @@ -451,22 +817,21 @@ float3 ApplyUserGradingAndToneMapAndScale(float3 untonemapped_bt709, tonemapped_bt709 = renodx::math::CopySign( ApplyAnchoredCInfinityShoulder(abs(untonemapped_graded_bt709), RENODX_PEAK_WHITE_NITS / RENODX_DIFFUSE_WHITE_NITS, 0.18f, 1.5f), untonemapped_graded_bt709); - } else { // LMS - float3 untonemapped_lms = renodx::color::lms::from::BT709(untonemapped_bt709); - const float3 anchor_lms = renodx::color::lms::from::BT709(0.18f); - - float3 untonemapped_graded_lms = ApplyAnchoredTonalGrading( - untonemapped_lms, - anchor_lms, anchor_lms, - RENODX_TONE_MAP_CONTRAST, 0.10f * pow(RENODX_TONE_MAP_FLARE, 10.f), - 1.f, 1.f, RENODX_TONE_MAP_HIGHLIGHTS, RENODX_TONE_MAP_SHADOWS); - untonemapped_graded_lms = ApplyPurityGradingLMS(untonemapped_graded_lms, RENODX_TONE_MAP_SATURATION, RENODX_TONE_MAP_HIGHLIGHT_SATURATION, 0.f, anchor_lms); - - const float3 peak_lms = renodx::color::lms::from::BT2020(RENODX_PEAK_WHITE_NITS / RENODX_DIFFUSE_WHITE_NITS); - float3 tonemapped_lms = ApplyAnchoredCInfinityShoulder(max(0, untonemapped_graded_lms), peak_lms, anchor_lms, 1.5f); - - tonemapped_bt709 = renodx::color::bt709::from::LMS(tonemapped_lms); + tonemapped_bt709 = ApplyCustomPsychoV25ToneMapWithEOTFEmulation( + untonemapped_bt709, + RENODX_PEAK_WHITE_NITS / RENODX_DIFFUSE_WHITE_NITS, + RENODX_TONE_MAP_HIGHLIGHTS, + RENODX_TONE_MAP_SHADOWS, + RENODX_TONE_MAP_CONTRAST, + 0.10f * pow(RENODX_TONE_MAP_FLARE, 10.f), + RENODX_TONE_MAP_SATURATION, + RENODX_TONE_MAP_HIGHLIGHT_SATURATION, + 0.f, + 0.f, + 0.18f, + 0.18f, + 0, 1.f, 1.5f, RENODX_GAMMA_CORRECTION); } tonemapped_bt709 = renodx::color::bt709::clamp::BT2020(tonemapped_bt709); From 95dbe76d4d07a6ff6f82a08a422199d92924cecf Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Tue, 11 Aug 2026 18:45:58 -0400 Subject: [PATCH 03/22] feat(elitedangerous): use c-infinity extension and bt709 displaymapping with Vanilla+ --- .../elitedangerous/tonemap/tonemap.hlsli | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/games/elitedangerous/tonemap/tonemap.hlsli b/src/games/elitedangerous/tonemap/tonemap.hlsli index 7a177c414..5a7a6c0d4 100644 --- a/src/games/elitedangerous/tonemap/tonemap.hlsli +++ b/src/games/elitedangerous/tonemap/tonemap.hlsli @@ -608,23 +608,28 @@ APPLY_VANILLA_TONEMAP_GENERATOR(float) APPLY_VANILLA_TONEMAP_GENERATOR(float3) #undef APPLY_VANILLA_TONEMAP_GENERATOR -#define APPLY_EXTENDED_VANILLA_TONEMAP_GENERATOR(T) \ - T ApplyExtendedVanillaTonemap(T x, float sdr_blend_strength = 0.f) { \ - const float INFLECTION_X = 0.119121851127f; \ - const float INFLECTION_Y = 0.163979921774f; \ - const float INFLECTION_SLOPE = 1.95752308422f; \ - \ - /* N4 = 0 removes the black clip. */ \ - T vanilla_gamma = ApplyVanillaTonemap(x, 0.f); \ - T vanilla_linear = pow(vanilla_gamma, 2.2f); \ - \ - T extended_linear = INFLECTION_Y + INFLECTION_SLOPE * (x - INFLECTION_X); \ - \ - T restored_linear = lerp(extended_linear, vanilla_linear, sdr_blend_strength); \ - \ - T output_linear = renodx::math::Select(x > INFLECTION_X, restored_linear, vanilla_linear); \ - \ - return max((T)0.f, output_linear); \ +#define APPLY_EXTENDED_VANILLA_TONEMAP_GENERATOR(T) \ + T ApplyExtendedVanillaTonemap(T x) { \ + const float INFLECTION_X = 0.119121851127f; \ + const float INFLECTION_Y = 0.163979921774f; \ + const float INFLECTION_SLOPE = 1.95752308422f; \ + /* r^2 * log2(e), where r = sqrt(2 * V'(x0) / abs(V'''(x0))). */ \ + const float NATURAL_RELEASE_EXP2_NUMERATOR = 0.0386582117104f; \ + \ + /* Vanilla+ uses N4 = 0 to remove the original black clip. */ \ + T vanilla_gamma = ApplyVanillaTonemap(x, 0.f); \ + T vanilla_linear = pow(vanilla_gamma, 2.2f); \ + \ + T distance_from_inflection = max(x - (T)INFLECTION_X, (T)0.f); \ + T tangent_linear = mad((T)INFLECTION_SLOPE, distance_from_inflection, (T)INFLECTION_Y); \ + T release_exponent = renodx::math::DivideSafe( \ + (T) - NATURAL_RELEASE_EXP2_NUMERATOR, \ + distance_from_inflection * distance_from_inflection, \ + (T) - renodx::math::FLT_MAX); \ + T release_weight = exp2(release_exponent); \ + release_weight = renodx::math::Select(x > (T)INFLECTION_X, release_weight, (T)0.f); \ + \ + return max((T)0.f, lerp(vanilla_linear, tangent_linear, release_weight)); \ } APPLY_EXTENDED_VANILLA_TONEMAP_GENERATOR(float) @@ -636,11 +641,10 @@ float3 ApplyPreLUTToneMapAndGammaEncode(float3 untonemapped) { if (RENODX_TONE_MAP_TYPE == 0.f) { tonemapped_gamma = ApplyVanillaTonemap(untonemapped); } else if (RENODX_TONE_MAP_TYPE == 1.f) { - float sdr_blend_strength = 0.f; - float3 tonemapped = ApplyExtendedVanillaTonemap(untonemapped, sdr_blend_strength); + float3 tonemapped = ApplyExtendedVanillaTonemap(untonemapped); if (RENODX_TONE_MAP_PER_CHANNEL == 0.f) { float perch_yf = renodx::color::yf::from::BT709(tonemapped); - float lum_yf = ApplyExtendedVanillaTonemap(renodx::color::yf::from::BT709(untonemapped), sdr_blend_strength); + float lum_yf = ApplyExtendedVanillaTonemap(renodx::color::yf::from::BT709(untonemapped)); tonemapped = renodx::color::correct::Luminance(tonemapped, perch_yf, lum_yf); } tonemapped_gamma = renodx::color::gamma::Encode(tonemapped, 2.2f); @@ -701,10 +705,12 @@ float3 ApplyPostLUTToneMap(float3 untonemapped_gamma) { RENODX_TONE_MAP_SATURATION, RENODX_TONE_MAP_HIGHLIGHT_SATURATION, RENODX_TONE_MAP_DECHROMA); - untonemapped = max(untonemapped, 1e-7f); + // untonemapped = max(untonemapped, 1e-7f); - tonemapped = ApplyAnchoredCInfinityShoulder(untonemapped, RENODX_PEAK_WHITE_NITS / RENODX_DIFFUSE_WHITE_NITS, MID_GRAY_OUT, 1.5f); - tonemapped = renodx::color::bt709::from::BT2020(tonemapped); + untonemapped = renodx::color::bt709::from::BT2020(untonemapped); + tonemapped = renodx::math::CopySign( + ApplyAnchoredCInfinityShoulder(abs(untonemapped), RENODX_PEAK_WHITE_NITS / RENODX_DIFFUSE_WHITE_NITS, MID_GRAY_OUT, 1.5f), + untonemapped); } else { // Custom tonemapped = ApplyCustomPsychoV25ToneMap( From f006dd88480358e76d26570acdf61b9dee793296 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Tue, 11 Aug 2026 18:46:27 -0400 Subject: [PATCH 04/22] chore(elitedangerous): code clean up --- src/games/elitedangerous/tonemap/tonemap.hlsli | 1 - 1 file changed, 1 deletion(-) diff --git a/src/games/elitedangerous/tonemap/tonemap.hlsli b/src/games/elitedangerous/tonemap/tonemap.hlsli index 5a7a6c0d4..888a8cb7f 100644 --- a/src/games/elitedangerous/tonemap/tonemap.hlsli +++ b/src/games/elitedangerous/tonemap/tonemap.hlsli @@ -627,7 +627,6 @@ APPLY_VANILLA_TONEMAP_GENERATOR(float3) distance_from_inflection * distance_from_inflection, \ (T) - renodx::math::FLT_MAX); \ T release_weight = exp2(release_exponent); \ - release_weight = renodx::math::Select(x > (T)INFLECTION_X, release_weight, (T)0.f); \ \ return max((T)0.f, lerp(vanilla_linear, tangent_linear, release_weight)); \ } From 634b7f3233c4995551107a349ab93fa715620f51 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Tue, 11 Aug 2026 22:25:16 -0400 Subject: [PATCH 05/22] chore(elitedangerous): remove unused code --- .../tonemap/psychov25/acc_dkl.hlsli | 354 -- .../tonemap/psychov25/bleaching.hlsli | 95 - .../tonemap/psychov25/customtest25.hlsli | 608 +++ .../tonemap/psychov25/nrg.hlsli | 919 ---- .../tonemap/psychov25/stockman.hlsli | 112 - .../tonemap/psychov25/test25.hlsli | 4085 ----------------- src/games/elitedangerous/tonemap/test24.hlsli | 554 --- .../elitedangerous/tonemap/tonemap.hlsli | 500 +- 8 files changed, 610 insertions(+), 6617 deletions(-) delete mode 100644 src/games/elitedangerous/tonemap/psychov25/acc_dkl.hlsli delete mode 100644 src/games/elitedangerous/tonemap/psychov25/bleaching.hlsli create mode 100644 src/games/elitedangerous/tonemap/psychov25/customtest25.hlsli delete mode 100644 src/games/elitedangerous/tonemap/psychov25/nrg.hlsli delete mode 100644 src/games/elitedangerous/tonemap/psychov25/stockman.hlsli delete mode 100644 src/games/elitedangerous/tonemap/psychov25/test25.hlsli delete mode 100644 src/games/elitedangerous/tonemap/test24.hlsli diff --git a/src/games/elitedangerous/tonemap/psychov25/acc_dkl.hlsli b/src/games/elitedangerous/tonemap/psychov25/acc_dkl.hlsli deleted file mode 100644 index 97f8740a3..000000000 --- a/src/games/elitedangerous/tonemap/psychov25/acc_dkl.hlsli +++ /dev/null @@ -1,354 +0,0 @@ -#ifndef SRC_SHADERS_COLOR_ACC_DKL_HLSL_ -#define SRC_SHADERS_COLOR_ACC_DKL_HLSL_ - -#include "./stockman.hlsli" - -namespace renodx { -namespace color { - -namespace acc { -// Generic ACC algebra: -// - lms_white defines the opponent matrix coefficients (mc1, mc2) -// - lms_background defines the operating point that LMS is delta'd against -// - matrix overloads allow a fully folded fast path when white/background are fixed -// - weighted/unweighted LMS use the same algebra; the separate entry points make -// the caller's basis choice explicit -static const float EPSILON = 1e-6f; - -float3 SafeLMSWhite(float3 lms_white = 1) { - return max(abs(lms_white), EPSILON.xxx); -} - -float2 ParamsFromLMSWhite(float3 lms_white = 1) { - float3 white = SafeLMSWhite(lms_white); - return float2( - renodx::math::DivideSafe(white.x, white.y, 0), - renodx::math::DivideSafe(white.x + white.y, white.z, 0)); -} - -float2 ParamsFromWeightedLMSWhite(float3 lms_weighted_white = 1) { - return ParamsFromLMSWhite(lms_weighted_white); -} - -float3x3 LMSDeltaToACCMatrix(float3 lms_white = 1) { - float2 acc_params = ParamsFromLMSWhite(lms_white); - float mc1 = acc_params.x; - float mc2 = acc_params.y; - - return float3x3( - 1.00000000f, 1.00000000f, 0.00000000f, - 1.00000000f, -mc1, 0.00000000f, - -1.00000000f, -1.00000000f, mc2); -} - -float3x3 WeightedLMSDeltaToACCMatrix(float3 lms_weighted_white = 1) { - return LMSDeltaToACCMatrix(lms_weighted_white); -} - -float3x3 ACCToLMSDeltaMatrix(float3 lms_white = 1) { - float2 acc_params = ParamsFromLMSWhite(lms_white); - float mc1 = acc_params.x; - float mc2 = acc_params.y; - - float inv_lm = renodx::math::DivideSafe(1.f, 1.f + mc1, 0); - float inv_s = renodx::math::DivideSafe(1.f, mc2, 0); - - return float3x3( - mc1 * inv_lm, inv_lm, 0.00000000f, - inv_lm, -inv_lm, 0.00000000f, - inv_s, 0.00000000f, inv_s); -} - -float3x3 ACCToWeightedLMSDeltaMatrix(float3 lms_weighted_white = 1) { - return ACCToLMSDeltaMatrix(lms_weighted_white); -} - -float3 BiasFromLMSBackground(float3x3 lms_delta_to_acc_mat, float3 lms_background = 0) { - return -mul(lms_delta_to_acc_mat, lms_background); -} - -namespace from { -float3 LMSDelta(float3 delta_lms, float3 lms_white = 1) { - return mul(LMSDeltaToACCMatrix(lms_white), delta_lms); -} - -float3 LMSDelta(float3 delta_lms, float3x3 lms_delta_to_acc_mat) { - return mul(lms_delta_to_acc_mat, delta_lms); -} - -float3 WeightedLMSDelta(float3 delta_lms_weighted, float3 lms_weighted_white = 1) { - return mul(WeightedLMSDeltaToACCMatrix(lms_weighted_white), delta_lms_weighted); -} - -float3 WeightedLMSDelta(float3 delta_lms_weighted, float3x3 weighted_lms_delta_to_acc_mat) { - return mul(weighted_lms_delta_to_acc_mat, delta_lms_weighted); -} - -float3 LMS(float3 lms, float3 lms_white = 1, float3 lms_background = 0) { - return LMSDelta(lms - lms_background, lms_white); -} - -float3 LMS(float3 lms, float3x3 lms_to_acc_mat, float3 acc_bias = 0) { - return mul(lms_to_acc_mat, lms) + acc_bias; -} - -float3 WeightedLMS(float3 lms_weighted, float3 lms_weighted_white = 1, - float3 lms_weighted_background = 0) { - return WeightedLMSDelta(lms_weighted - lms_weighted_background, lms_weighted_white); -} - -float3 WeightedLMS(float3 lms_weighted, float3x3 weighted_lms_to_acc_mat, float3 acc_bias = 0) { - return mul(weighted_lms_to_acc_mat, lms_weighted) + acc_bias; -} -} // namespace from - -namespace to { -float3 LMSDelta(float3 acc_value, float3 lms_white = 1) { - return mul(ACCToLMSDeltaMatrix(lms_white), acc_value); -} - -float3 LMSDelta(float3 acc_value, float3x3 acc_to_lms_delta_mat) { - return mul(acc_to_lms_delta_mat, acc_value); -} - -float3 WeightedLMSDelta(float3 acc_value, float3 lms_weighted_white = 1) { - return mul(ACCToWeightedLMSDeltaMatrix(lms_weighted_white), acc_value); -} - -float3 WeightedLMSDelta(float3 acc_value, float3x3 acc_to_weighted_lms_delta_mat) { - return mul(acc_to_weighted_lms_delta_mat, acc_value); -} - -float3 LMS(float3 acc_value, float3 lms_white = 1, float3 lms_background = 0) { - return LMSDelta(acc_value, lms_white) + lms_background; -} - -float3 LMS(float3 acc_value, float3x3 acc_to_lms_delta_mat, float3 lms_background = 0) { - return mul(acc_to_lms_delta_mat, acc_value) + lms_background; -} - -float3 WeightedLMS(float3 acc_value, float3 lms_weighted_white = 1, - float3 lms_weighted_background = 0) { - return WeightedLMSDelta(acc_value, lms_weighted_white) + lms_weighted_background; -} - -float3 WeightedLMS(float3 acc_value, float3x3 acc_to_weighted_lms_delta_mat, - float3 lms_weighted_background = 0) { - return mul(acc_to_weighted_lms_delta_mat, acc_value) + lms_weighted_background; -} -} // namespace to -} // namespace acc - -namespace dkl { -namespace from { -float3 LMSDelta(float3 delta_lms, float3 lms_white = 1) { - return acc::from::LMSDelta(delta_lms, lms_white); -} - -float3 LMSDelta(float3 delta_lms, float3x3 lms_delta_to_dkl_mat) { - return acc::from::LMSDelta(delta_lms, lms_delta_to_dkl_mat); -} - -float3 WeightedLMSDelta(float3 delta_lms_weighted, float3 lms_weighted_white = 1) { - return acc::from::WeightedLMSDelta(delta_lms_weighted, lms_weighted_white); -} - -float3 WeightedLMSDelta(float3 delta_lms_weighted, float3x3 weighted_lms_delta_to_dkl_mat) { - return acc::from::WeightedLMSDelta(delta_lms_weighted, weighted_lms_delta_to_dkl_mat); -} - -float3 LMS(float3 lms, float3 lms_white = 1, float3 lms_background = 0) { - return acc::from::LMS(lms, lms_white, lms_background); -} - -float3 LMS(float3 lms, float3x3 lms_to_dkl_mat, float3 dkl_bias = 0) { - return acc::from::LMS(lms, lms_to_dkl_mat, dkl_bias); -} - -float3 WeightedLMS(float3 lms_weighted, float3 lms_weighted_white = 1, - float3 lms_weighted_background = 0) { - return acc::from::WeightedLMS(lms_weighted, lms_weighted_white, lms_weighted_background); -} - -float3 WeightedLMS(float3 lms_weighted, float3x3 weighted_lms_to_dkl_mat, float3 dkl_bias = 0) { - return acc::from::WeightedLMS(lms_weighted, weighted_lms_to_dkl_mat, dkl_bias); -} -} // namespace from - -namespace to { -float3 LMSDelta(float3 dkl_value, float3 lms_white = 1) { - return acc::to::LMSDelta(dkl_value, lms_white); -} - -float3 LMSDelta(float3 dkl_value, float3x3 dkl_to_lms_delta_mat) { - return acc::to::LMSDelta(dkl_value, dkl_to_lms_delta_mat); -} - -float3 WeightedLMSDelta(float3 dkl_value, float3 lms_weighted_white = 1) { - return acc::to::WeightedLMSDelta(dkl_value, lms_weighted_white); -} - -float3 WeightedLMSDelta(float3 dkl_value, float3x3 dkl_to_weighted_lms_delta_mat) { - return acc::to::WeightedLMSDelta(dkl_value, dkl_to_weighted_lms_delta_mat); -} - -float3 LMS(float3 dkl_value, float3 lms_white = 1, float3 lms_background = 0) { - return acc::to::LMS(dkl_value, lms_white, lms_background); -} - -float3 LMS(float3 dkl_value, float3x3 dkl_to_lms_delta_mat, float3 lms_background = 0) { - return acc::to::LMS(dkl_value, dkl_to_lms_delta_mat, lms_background); -} - -float3 WeightedLMS(float3 dkl_value, float3 lms_weighted_white = 1, - float3 lms_weighted_background = 0) { - return acc::to::WeightedLMS(dkl_value, lms_weighted_white, lms_weighted_background); -} - -float3 WeightedLMS(float3 dkl_value, float3x3 dkl_to_weighted_lms_delta_mat, - float3 lms_weighted_background = 0) { - return acc::to::WeightedLMS(dkl_value, dkl_to_weighted_lms_delta_mat, lms_weighted_background); -} -} // namespace to -} // namespace dkl - -namespace stockman { -namespace acc { -// Concrete Stockman ACC uses Stockman D65 as the white that defines the matrix. -// The optional background remains caller-controlled and defaults to zero delta. -float3 LMSWhite() { - return renodx::color::lms::from::WhiteD65(); -} - -float2 Params() { - return renodx::color::acc::ParamsFromLMSWhite(LMSWhite()); -} - -float3x3 LMSDeltaToACCMatrix() { - return renodx::color::acc::LMSDeltaToACCMatrix(LMSWhite()); -} - -float3x3 ACCToLMSDeltaMatrix() { - return renodx::color::acc::ACCToLMSDeltaMatrix(LMSWhite()); -} - -float3x3 LMSD65ToACCMatrix() { - return renodx::color::acc::LMSDeltaToACCMatrix(1); -} - -float3x3 ACCToLMSD65Matrix() { - return renodx::color::acc::ACCToLMSDeltaMatrix(1); -} - -namespace from { -float3 LMSDelta(float3 delta_lms) { - return renodx::color::acc::from::LMSDelta(delta_lms, stockman::acc::LMSDeltaToACCMatrix()); -} - -float3 LMS(float3 lms_abs, float3 lms_background = 0) { - return renodx::color::acc::from::LMS( - lms_abs, - stockman::acc::LMSDeltaToACCMatrix(), - renodx::color::acc::BiasFromLMSBackground( - stockman::acc::LMSDeltaToACCMatrix(), - lms_background)); -} - -float3 BT709(float3 bt709, float3 lms_background = 0) { - return LMS(lms::from::BT709(bt709), lms_background); -} - -float3 BT2020(float3 bt2020, float3 lms_background = 0) { - return LMS(lms::from::BT2020(bt2020), lms_background); -} - -float3 LMSD65(float3 lms_d65, float3 lms_background = 0) { - return renodx::color::acc::from::LMS( - lms_d65, - stockman::acc::LMSD65ToACCMatrix(), - renodx::color::acc::BiasFromLMSBackground( - stockman::acc::LMSD65ToACCMatrix(), - lms_background)); -} -} // namespace from - -namespace to { -float3 LMSDelta(float3 acc_value) { - return renodx::color::acc::to::LMSDelta(acc_value, stockman::acc::ACCToLMSDeltaMatrix()); -} - -float3 LMS(float3 acc_value, float3 lms_background = 0) { - return renodx::color::acc::to::LMS( - acc_value, - stockman::acc::ACCToLMSDeltaMatrix(), - lms_background); -} - -float3 BT709(float3 acc_value, float3 lms_background = 0) { - return bt709::from::LMS(LMS(acc_value, lms_background)); -} - -float3 BT2020(float3 acc_value, float3 lms_background = 0) { - return bt2020::from::LMS(LMS(acc_value, lms_background)); -} - -float3 LMSD65(float3 acc_value, float3 lms_background = 0) { - return renodx::color::acc::to::LMS( - acc_value, - stockman::acc::ACCToLMSD65Matrix(), - lms_background); -} -} // namespace to -} // namespace acc - -namespace dkl { -namespace from { -float3 LMSDelta(float3 delta_lms) { - return acc::from::LMSDelta(delta_lms); -} - -float3 LMS(float3 lms_abs, float3 lms_background = 0) { - return acc::from::LMS(lms_abs, lms_background); -} - -float3 BT709(float3 bt709, float3 lms_background = 0) { - return acc::from::BT709(bt709, lms_background); -} - -float3 BT2020(float3 bt2020, float3 lms_background = 0) { - return acc::from::BT2020(bt2020, lms_background); -} - -float3 LMSD65(float3 lms_d65, float3 lms_background = 0) { - return acc::from::LMSD65(lms_d65, lms_background); -} -} // namespace from - -namespace to { -float3 LMSDelta(float3 dkl_value) { - return acc::to::LMSDelta(dkl_value); -} - -float3 LMS(float3 dkl_value, float3 lms_background = 0) { - return acc::to::LMS(dkl_value, lms_background); -} - -float3 BT709(float3 dkl_value, float3 lms_background = 0) { - return acc::to::BT709(dkl_value, lms_background); -} - -float3 BT2020(float3 dkl_value, float3 lms_background = 0) { - return acc::to::BT2020(dkl_value, lms_background); -} - -float3 LMSD65(float3 dkl_value, float3 lms_background = 0) { - return acc::to::LMSD65(dkl_value, lms_background); -} -} // namespace to -} // namespace dkl -} // namespace stockman - -} // namespace color -} // namespace renodx - -#endif // SRC_SHADERS_COLOR_ACC_DKL_HLSL_ diff --git a/src/games/elitedangerous/tonemap/psychov25/bleaching.hlsli b/src/games/elitedangerous/tonemap/psychov25/bleaching.hlsli deleted file mode 100644 index b1b5a9146..000000000 --- a/src/games/elitedangerous/tonemap/psychov25/bleaching.hlsli +++ /dev/null @@ -1,95 +0,0 @@ -#ifndef SRC_SHADERS_COLOR_BLEACHING_HLSL_ -#define SRC_SHADERS_COLOR_BLEACHING_HLSL_ - -#include "../../common.hlsli" - -namespace renodx { -namespace color { -namespace bleaching { - -namespace rushton_henry { - -static const float CONE_HALF_BLEACH_TROLANDS = 20000.f; - -// One-sided availability limiter in adapted units. -// p(r) = 1 / (1 + r / r0) -// Source direction: same algebraic form as the steady-state cone bleaching law -// used by Rushton & Henry (1968), commonly written for fraction bleached as -// p_bleached(I) = I / (I + I0) -// with I in photopic trolands and I0 ~ 10^4.3 Td for cones. This helper uses -// the complementary fraction -// p_available(I) = 1 - p_bleached(I) = I0 / (I + I0) -// because the shader attenuates available cone drive rather than tracking the -// bleached fraction directly. -// Secondary source with the equation stated explicitly: -// Stockman, Henning, Smithson, & Rider (JOV 2018, 18(6):12), appendix note: -// "p = I / (I + I0)", with I0 = 10^4.3 Td, citing Rushton & Henry (1968). -float AvailabilityFromRelativeDrive(float relative_drive, float knee_ratio) { - return 1.f / (1.f + relative_drive / knee_ratio); -} - -// Absolute trolands form of the same availability law. -// p(I) = 1 / (1 + I / I0) -float AvailabilityFromTrolands(float retinal_illuminance_trolands, - float half_bleach_trolands = CONE_HALF_BLEACH_TROLANDS) { - return 1.f / (1.f + retinal_illuminance_trolands / half_bleach_trolands); -} - -float3 AvailabilityFromTrolands(float3 retinal_illuminance_trolands, - float half_bleach_trolands = CONE_HALF_BLEACH_TROLANDS) { - return float3( - AvailabilityFromTrolands(retinal_illuminance_trolands.x, half_bleach_trolands), - AvailabilityFromTrolands(retinal_illuminance_trolands.y, half_bleach_trolands), - AvailabilityFromTrolands(retinal_illuminance_trolands.z, half_bleach_trolands)); -} - -} // namespace rushton_henry - -// White-relative per-cone attenuation: -// - Keeps a white anchor at the same L+M level as the input. -// - Applies independent cone gains to LMS deltas around that anchor. -// Engineering interpretation: -// - The bleaching source law above constrains available pigment / sensitivity. -// - The specific "bleach toward white at the same carried achromatic level" -// behavior implemented here is the repo's rendering model for color signals, -// not a literal equation from Rushton & Henry. It is chosen so that strong -// bleaching suppresses cone-opponent excursions while preserving the -// achromatic anchor. -// - CVRL notes that bleaching also reduces effective photopigment density and -// therefore narrows spectral sensitivity without shifting lambda_max. This -// helper does not model that wavelength-dependent narrowing; it is a -// first-order scalar availability approximation intended for rendering. -// - CVRL also notes that a reliable S-cone half-bleaching constant has not -// been established. The shared cone knee used here is therefore an -// engineering approximation rather than a fully resolved per-cone -// physiological model. -float3 ApplyAvailabilityToLMSPerConeWhiteRelative(float3 lms, float3 availability_lms, - float3 white_lms) { - float y = lms.x + lms.y; - float white_y = white_lms.x + white_lms.y; - float3 white_at_y = white_lms * (y / white_y); - float3 delta = lms - white_at_y; - delta *= availability_lms; - - return white_at_y + delta; -} - -float3 ComputeAvailabilityFromAdaptedLMS(float3 adapted_lms, float blend, - float diffuse_white_nits = 100.f, - float pupil_area_mm2 = 10.f, - float half_bleach_trolands = - rushton_henry::CONE_HALF_BLEACH_TROLANDS) { - float3 stimulus_trolands = max(adapted_lms, 0) * diffuse_white_nits * pupil_area_mm2; - float3 availability = rushton_henry::AvailabilityFromTrolands( - stimulus_trolands, half_bleach_trolands); - - return lerp(1.f, availability, blend); -} - - - -} // namespace bleaching -} // namespace color -} // namespace renodx - -#endif // SRC_SHADERS_COLOR_BLEACHING_HLSL_ diff --git a/src/games/elitedangerous/tonemap/psychov25/customtest25.hlsli b/src/games/elitedangerous/tonemap/psychov25/customtest25.hlsli new file mode 100644 index 000000000..df2004d50 --- /dev/null +++ b/src/games/elitedangerous/tonemap/psychov25/customtest25.hlsli @@ -0,0 +1,608 @@ +#ifndef RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ +#define RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ + +#include "../../common.hlsli" + +/* + * Copyright (C) 2026 Carlos Lopez + * SPDX-License-Identifier: MIT + */ + +namespace renodx { +namespace tonemap { +namespace psychov { + +static const float PSYCHO25_EPSILON = 1e-6f; +static const float PSYCHO25_LARGE = 1e20f; +static const float PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE = 0.9f; +static const float PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION = 0.75f; +static const float PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON = 1e-5f; +static const float PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER = 256.f; +static const float PSYCHO25_HUE_AMPLITUDE = 0.5f; +static const float PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY = 0.8f; +static const float PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION = 0.25f; +static const float PSYCHO25_SOURCE_DIRECTION_BLEND_POWER = 2.f; + +float psycho25_SignedYfFromLMS(float3 lms) { + float3 weighted_lms = renodx::color::macleod_boynton::WeighLMS(lms); + return weighted_lms.x + weighted_lms.y; +} + +float psycho25_YfFromLMS(float3 lms) { + return max(psycho25_SignedYfFromLMS(lms), PSYCHO25_EPSILON); +} + +float3 psycho25_ToAdaptiveRelativeWeightedLMS( + float3 lms_input, + float3 current_adaptive_state_lms) { + return renodx::math::DivideSafe( + renodx::color::macleod_boynton::WeighLMS(lms_input), + current_adaptive_state_lms, + 0.f.xxx); +} + +float3 psycho25_FromAdaptiveRelativeWeightedLMS( + float3 lms_weighted_relative, + float3 current_adaptive_state_lms) { + return lms_weighted_relative + * max(current_adaptive_state_lms, PSYCHO25_EPSILON.xxx); +} + +float3 psycho25_LMSFromAdaptiveMB( + float3 mb, + float3 current_adaptive_state_lms) { + float3 relative_weighted = + renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton(mb); + return renodx::color::macleod_boynton::UnweighLMS( + psycho25_FromAdaptiveRelativeWeightedLMS( + relative_weighted, + current_adaptive_state_lms)); +} + +float3 psycho25_ApplyAdaptiveMBPurity( + float3 lms_input, + float3 adaptive_neutral_lms, + float purity_delta) { + if (abs(purity_delta - 1.f) <= 1e-5f) return lms_input; + + float3 relative_weighted = psycho25_ToAdaptiveRelativeWeightedLMS( + lms_input, + adaptive_neutral_lms); + float3 mb = renodx::color::macleod_boynton::from::WeightedLMS( + relative_weighted); + float3 mb_neutral = renodx::color::macleod_boynton::from::LMS(1.f.xxx); + float2 mb_scaled_xy = lerp(mb_neutral.xy, mb.xy, purity_delta); + float3 relative_weighted_out = + renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton( + float3(mb_scaled_xy, mb.z)); + return renodx::color::macleod_boynton::UnweighLMS( + psycho25_FromAdaptiveRelativeWeightedLMS( + relative_weighted_out, + adaptive_neutral_lms)); +} + +float3x3 psycho25_WeightedLMSToRGBMatrix(int gamut_mode) { + return gamut_mode == 0 + ? renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT709_MAT + : renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT2020_MAT; +} + +float3 psycho25_TargetRGBFromLMS(float3 lms, int gamut_mode) { + return mul( + psycho25_WeightedLMSToRGBMatrix(gamut_mode), + renodx::color::macleod_boynton::WeighLMS(lms)); +} + +float psycho25_TargetLowerPlaneBoundaryFraction( + float3 candidate_target_rgb, + float3 neutral_target_rgb) { + float boundary_fraction = PSYCHO25_LARGE; + if (candidate_target_rgb.x < neutral_target_rgb.x) { + boundary_fraction = min( + boundary_fraction, + neutral_target_rgb.x + / (neutral_target_rgb.x - candidate_target_rgb.x)); + } + if (candidate_target_rgb.y < neutral_target_rgb.y) { + boundary_fraction = min( + boundary_fraction, + neutral_target_rgb.y + / (neutral_target_rgb.y - candidate_target_rgb.y)); + } + if (candidate_target_rgb.z < neutral_target_rgb.z) { + boundary_fraction = min( + boundary_fraction, + neutral_target_rgb.z + / (neutral_target_rgb.z - candidate_target_rgb.z)); + } + return boundary_fraction; +} + +float psycho25_CompressTargetLowerPlaneRadius(float boundary_fraction) { + float knee = PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE * boundary_fraction; + float headroom = boundary_fraction - knee; + float excess = max(1.f - knee, 0.f); + return 1.f - excess + + renodx::math::DivideSafe( + headroom * excess, + headroom + excess, + 0.f); +} + +float psycho25_SmoothPositive(float value) { + float smooth_length = sqrt( + value * value + + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON + * PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); + float normalized_value = value / smooth_length; + return 0.5f * value * normalized_value * (1.f + normalized_value); +} + +float psycho25_IntersectTargetPlaneSupports(float a, float b) { + float normalization = max(a, b); + float normalized_a = a / normalization; + float normalized_b = b / normalization; + float denominator = normalization + * pow( + pow(normalized_a, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER) + + pow(normalized_b, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER), + rcp(PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER)); + return a * b / denominator; +} + +float psycho25_IntersectTargetPlaneSupports(float3 support) { + return psycho25_IntersectTargetPlaneSupports( + support.x, + psycho25_IntersectTargetPlaneSupports(support.y, support.z)); +} + +float psycho25_TargetLowerPlaneRadiusForDirection( + float2 direction, + float2 adapted_neutral_mb, + float3 current_adaptive_state_lms, + int target_gamut_mode) { + float3 neutral_lms = psycho25_LMSFromAdaptiveMB( + float3(adapted_neutral_mb, 1.f), + current_adaptive_state_lms); + float3 unit_radius_lms = psycho25_LMSFromAdaptiveMB( + float3(adapted_neutral_mb + direction, 1.f), + current_adaptive_state_lms); + float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( + neutral_lms, + target_gamut_mode); + float3 direction_target_rgb = psycho25_TargetRGBFromLMS( + unit_radius_lms - neutral_lms, + target_gamut_mode); + float3 lower_support = neutral_target_rgb + / (float3( + psycho25_SmoothPositive(-direction_target_rgb.x), + psycho25_SmoothPositive(-direction_target_rgb.y), + psycho25_SmoothPositive(-direction_target_rgb.z)) + + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); + return psycho25_IntersectTargetPlaneSupports(lower_support); +} + +} // namespace psychov +} // namespace tonemap +} // namespace renodx + +float3 ComputeCInfinityTransition(float3 position) { + position = saturate(position); + return 1.f / (1.f + exp2((1.f - 2.f * position) / (position * (1.f - position)))); +} + +// Monotonic and C-infinity continuous anchored tonal grading +float3 ApplyAnchoredTonalGrading( + float3 color, + float3 anchor_in = 0.18f, + float3 anchor_out = 0.18f, + float contrast = 1.f, + float flare = 0.f, + float highlight_contrast = 1.f, + float shadow_contrast = 1.f, + float highlights = 1.f, + float shadows = 1.f) { + [branch] + if (contrast == 1.f + && flare == 0.f + && highlight_contrast == 1.f + && shadow_contrast == 1.f + && highlights == 1.f + && shadows == 1.f + && all(anchor_in == anchor_out)) { + return color; + } + + float3 ax = abs(color); + float3 normalized = ax / anchor_in; + float3 contrasted_normalized = normalized; + + // Power contrast and shadow flare, optionally bounding contrast on highlights. + [branch] + if (contrast != 1.f || flare > 0.f) { + float3 exponent = contrast; + + [branch] + if (flare > 0.f) { + float3 shadow_distance = saturate(1.f - normalized); + float3 flat_shadow_weight = exp2(-normalized / shadow_distance); + exponent *= mad(flat_shadow_weight, flare / (normalized + flare), 1.f); + } + +#if 1 + float3 input_stops = log2(normalized); + float3 highlight_stops = max(input_stops, 0.f); + float3 output_highlight_stops = highlight_stops; + + [branch] + if (contrast != 1.f) { + float3 contrast_displacement = (contrast - 1.f) * highlight_stops; + float3 displacement_magnitude = abs(contrast_displacement); + output_highlight_stops += contrast_displacement / mad(displacement_magnitude, exp2(-1.f / displacement_magnitude), 1.f); + } + + contrasted_normalized = exp2(mad(exponent, min(input_stops, 0.f), output_highlight_stops)); +#else + contrasted_normalized = pow(normalized, exponent); +#endif + } + + // broad highlight contrast. + [branch] + if (highlight_contrast != 1.f) { + float3 highlight_distance = max(contrasted_normalized - 1.f, 0.f); + float3 highlight_distance_squared = highlight_distance * highlight_distance; + float3 flat_highlight_distance = (1.f + highlight_distance_squared) * exp2(-1.f / highlight_distance_squared); + contrasted_normalized += highlight_distance * (pow(1.f + flat_highlight_distance, 0.5f * (highlight_contrast - 1.f)) - 1.f); + } + + // broad shadow contrast. + [branch] + if (shadow_contrast != 1.f) { + float3 shadow_distance = saturate(1.f - contrasted_normalized); + float3 shadow_distance_squared = shadow_distance * shadow_distance; + float3 flat_shadow_distance = shadow_distance_squared * shadow_distance * exp2(1.f - 1.f / shadow_distance_squared); + contrasted_normalized *= pow(1.f + flat_shadow_distance, shadow_contrast - 1.f); + } + + // Mirror offsets about the anchor over the declared stop range. + [branch] + if (highlights != 1.f || shadows != 1.f) { + static const float TONAL_OFFSET_START_STOPS = 1.f; + static const float TONAL_OFFSET_END_STOPS = 8.f; + static const float TONAL_OFFSET_INVERSE_RANGE_STOPS = 1.f / (TONAL_OFFSET_END_STOPS - TONAL_OFFSET_START_STOPS); + + float3 tonal_stops = log2(contrasted_normalized); + float3 tonal_displacement = 0.f; + + [branch] + if (highlights != 1.f) { + float highlight_adjustment = highlights - 1.f; + float highlight_displacement = highlight_adjustment * mad(1.5f, abs(highlight_adjustment), 0.5f); + float3 highlight_weight = ComputeCInfinityTransition((tonal_stops - TONAL_OFFSET_START_STOPS) * TONAL_OFFSET_INVERSE_RANGE_STOPS); + tonal_displacement = mad(highlight_displacement, highlight_weight, tonal_displacement); + } + + [branch] + if (shadows != 1.f) { + float shadow_adjustment = shadows - 1.f; + float shadow_displacement = shadow_adjustment * mad(1.5f, abs(shadow_adjustment), 0.5f); + float3 shadow_weight = ComputeCInfinityTransition((-TONAL_OFFSET_START_STOPS - tonal_stops) * TONAL_OFFSET_INVERSE_RANGE_STOPS); + tonal_displacement = mad(shadow_displacement, shadow_weight, tonal_displacement); + } + + contrasted_normalized *= exp2(tonal_displacement); + } + + return renodx::math::CopySign(contrasted_normalized * anchor_out, color); +} + +/// Identity through anchor to every derivative; then approaches peak +/// monotonically and concave down. Requires anchor < peak and compression_strength >= 1. +#define APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(T) \ + T ApplyAnchoredCInfinityShoulder(T color, T peak, T anchor, float compression_strength) { \ + T shoulder_range = peak - anchor; \ + T distance_from_anchor = max(color - anchor, (T)0.f); \ + T flat_weight = exp2(-shoulder_range / (compression_strength * distance_from_anchor)); \ + T response_denominator = mad(distance_from_anchor, flat_weight, shoulder_range); \ + return mad(shoulder_range, distance_from_anchor / response_denominator, color - distance_from_anchor); \ + } + +APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float) +APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float3) +#undef APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR + +float ApplyAnchoredCInfinityShoulderMaxChannelScale(float3 color, float peak, float anchor, float compression_strength) { + float max_channel = renodx::math::Max(abs(color)); + float compressed_max = ApplyAnchoredCInfinityShoulder(max_channel, peak, anchor, compression_strength); + return renodx::math::DivideSafe(compressed_max, max_channel, 1.f); +} + +// Fixed PsychoV25 target-hull path: Fast60 hue guidance, Reference Scale, +// full BT.2020 lower/upper-plane enforcement, and a black upper-hull pivot. +float3 CompressPsychoV25ReferenceScaleHull( + float3 desired_lms, + float3 direction_source_lms, + float3 adaptive_state_lms, + float3 background_state_lms, + float3 target_lms_peak, + float source_direction_recovery_strength, + float compression, + float peak_value) { + float3 desired_weighted_lms = renodx::color::macleod_boynton::WeighLMS(desired_lms); + float desired_yf = desired_weighted_lms.x + desired_weighted_lms.y; + if (desired_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { + return 0.f.xxx; + } + + float adaptive_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(adaptive_state_lms); + float background_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(background_state_lms); + float target_peak_yf = renodx::tonemap::psychov::psycho25_SignedYfFromLMS(target_lms_peak); + float3 physical_compressed_lms = ApplyAnchoredCInfinityShoulder( + desired_lms, + target_lms_peak, + background_state_lms, + compression); + float authored_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(physical_compressed_lms); + if (authored_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { + return 0.f.xxx; + } + + float3 safe_adaptive_state_lms = max( + adaptive_state_lms, + renodx::tonemap::psychov::PSYCHO25_EPSILON.xxx); + float2 adapted_neutral_mb = renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 authored_mb = renodx::color::macleod_boynton::from::WeightedLMS( + renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( + physical_compressed_lms, + adaptive_state_lms)); + float3 source_mb = renodx::color::macleod_boynton::from::WeightedLMS( + renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( + direction_source_lms, + adaptive_state_lms)); + + // Fast60: retain physical radius and use the angular midpoint between the + // source direction and the raw per-cone-compressed direction. + float2 authored_offset = authored_mb.xy - adapted_neutral_mb; + float2 source_offset = source_mb.xy - adapted_neutral_mb; + float authored_radius2 = dot(authored_offset, authored_offset); + float source_radius2 = dot(source_offset, source_offset); + if (authored_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON + && source_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON) { + float2 source_direction = source_offset * rsqrt(source_radius2); + float2 compressed_direction = authored_offset * rsqrt(authored_radius2); + float2 output_direction = lerp( + source_direction, + compressed_direction, + 1.f - renodx::tonemap::psychov::PSYCHO25_HUE_AMPLITUDE); + float output_direction2 = dot(output_direction, output_direction); + if (output_direction2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON) { + authored_mb.xy = adapted_neutral_mb + + output_direction * rsqrt(output_direction2) * sqrt(authored_radius2); + authored_offset = authored_mb.xy - adapted_neutral_mb; + authored_radius2 = dot(authored_offset, authored_offset); + } + } + + float authored_radius = sqrt(authored_radius2); + float2 authored_direction = authored_offset * rsqrt(authored_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); + + // Reference Scale source-direction recovery keeps collapsing saturated + // highlights from rotating through an unrelated hue on their way to white. + [branch] + if (source_direction_recovery_strength > 0.f) { + float source_radius = sqrt(source_radius2); + float2 source_direction = source_offset * rsqrt(source_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); + float source_radius_support = + renodx::tonemap::psychov::psycho25_TargetLowerPlaneRadiusForDirection( + source_direction, + adapted_neutral_mb, + adaptive_state_lms, + 1); + float source_direction_support_radius = + renodx::tonemap::psychov::PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY + * source_radius_support + * renodx::math::DivideSafe( + source_radius, + sqrt(source_radius2 + source_radius_support * source_radius_support), + 0.f); + float radius_normalization = max( + max(authored_radius, source_direction_support_radius), + renodx::tonemap::psychov::PSYCHO25_EPSILON); + float authored_weight = pow( + authored_radius / radius_normalization, + renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); + float source_direction_support_weight = pow( + source_direction_support_radius / radius_normalization, + renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); + float source_hue_support = + renodx::tonemap::psychov::PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION + * source_radius_support; + float source_hue_confidence = renodx::math::DivideSafe( + source_radius2, + source_radius2 + source_hue_support * source_hue_support, + 0.f); + float source_collapse_weight = renodx::math::DivideSafe( + source_direction_support_weight, + authored_weight + source_direction_support_weight, + 0.f); + float source_direction_weight = source_direction_recovery_strength + * (1.f - (1.f - source_hue_confidence) * (1.f - source_collapse_weight)); + float2 combined_direction = lerp( + authored_direction, + source_direction, + source_direction_weight); + combined_direction *= rsqrt( + dot(combined_direction, combined_direction) + + renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON); + authored_direction = combined_direction; + authored_offset = authored_direction * authored_radius; + authored_mb.xy = adapted_neutral_mb + authored_offset; + } + + // Discard the trajectory's carried scale, preserving only its authored + // adaptive-MB direction and radius before solving the BT.2020 hull. + float trajectory_yf_for_normalization = authored_mb.z + * (authored_mb.x * safe_adaptive_state_lms.x + + (1.f - authored_mb.x) * safe_adaptive_state_lms.y); + float3 unit_yf_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( + float3( + authored_mb.xy, + renodx::math::DivideSafe( + authored_mb.z, + trajectory_yf_for_normalization, + 0.f)), + adaptive_state_lms); + float3 neutral_lms = adaptive_state_lms / adaptive_yf; + + // Reference Scale lower-plane compression keeps the authored hue ray inside + // the nonnegative BT.2020 primary half-spaces without a component clamp. + if (authored_radius > renodx::tonemap::psychov::PSYCHO25_EPSILON) { + float3 neutral_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(neutral_lms, 1); + float3 current_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); + float current_boundary_fraction = + renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( + current_target_rgb, + neutral_target_rgb); + float current_radius_scale = + renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( + current_boundary_fraction); + + authored_direction = authored_offset / authored_radius; + float containment_reference_radius = max( + authored_radius, + length(source_mb.xy - adapted_neutral_mb)); + float3 reference_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( + float3( + adapted_neutral_mb + + authored_direction * containment_reference_radius, + 1.f), + adaptive_state_lms); + reference_lms /= renodx::tonemap::psychov::psycho25_YfFromLMS(reference_lms); + float3 reference_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(reference_lms, 1); + float reference_boundary_fraction = + renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( + reference_target_rgb, + neutral_target_rgb); + float reference_radius_scale = + renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( + reference_boundary_fraction); + + float trajectory_fraction = authored_radius / containment_reference_radius; + float release_progress = saturate( + trajectory_fraction + / renodx::tonemap::psychov::PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION); + float neutral_scale = min(1.f, 4.f * reference_radius_scale); + float release_weight = 1.f - release_progress; + float radius_scale = min( + lerp( + reference_radius_scale, + neutral_scale, + release_weight * release_weight), + current_radius_scale); + unit_yf_lms = lerp(neutral_lms, unit_yf_lms, radius_scale); + } + + // Black-pivot upper-plane shoulder along the contained BT.2020 hue ray. + float3 unit_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); + float max_target_channel = max( + unit_target_rgb.x, + max(unit_target_rgb.y, unit_target_rgb.z)); + float directional_yf_limit = peak_value / max_target_channel; + float normalized_input = desired_yf * renodx::math::DivideSafe(target_peak_yf, directional_yf_limit, 1.f); + float normalized_output = ApplyAnchoredCInfinityShoulder( + normalized_input, + target_peak_yf, + background_yf, + compression); + float output_yf = normalized_output * renodx::math::DivideSafe(directional_yf_limit, target_peak_yf, 1.f); + return unit_yf_lms * output_yf; +} + +float3 ApplyCustomPsychoV25ToneMap( + float3 bt709_linear_input, + float peak_value, + float highlights, + float shadows, + float cone_response_exponent, + float flare, + float purity_scale, + float highlight_saturation, + float dechroma, + float source_direction_recovery_strength = 0.f, + float3 current_adaptive_state_bt709 = 0.18f, + float3 current_background_state_bt709 = 0.18f, + float compression = 1.5f) { + float3 finite_bt709_input = renodx::math::ZeroNaN(bt709_linear_input); + finite_bt709_input = renodx::math::Select( + isinf(finite_bt709_input), + renodx::math::CopySign(65504.f.xxx, finite_bt709_input), + finite_bt709_input); + + float3 lms_in = renodx::color::lms::from::BT709(finite_bt709_input); + float3 current_adaptive_state_lms = renodx::color::lms::from::BT709(current_adaptive_state_bt709); + float3 current_background_state_lms = renodx::color::lms::from::BT709(current_background_state_bt709); + float3 target_lms_peak = renodx::color::lms::from::BT709(peak_value.xxx); + + if (dechroma != 0.f || highlight_saturation != 1.f) { + float luminance = renodx::color::yf::from::LMS(lms_in); + float neutral_luminance = renodx::color::yf::from::LMS(current_adaptive_state_lms); + + // Ramp purity grading over 2.75 decades above the adaptive neutral. + static const float INVERSE_HIGHLIGHT_RANGE_STOPS = 1.f / (2.75f * log2(10.f)); + static const float HIGHLIGHT_ROLLOFF_CUBIC_BLEND = 0.5f; + static const float HIGHLIGHT_PURITY_STRENGTH = 2.f / 3.f; + + float luminance_from_neutral = max(luminance, neutral_luminance) / neutral_luminance; + float rolloff_position = saturate(log2(luminance_from_neutral) * INVERSE_HIGHLIGHT_RANGE_STOPS); + float rolloff_position_squared = rolloff_position * rolloff_position; + float rolloff = rolloff_position_squared * rolloff_position * mad(rolloff_position, mad(6.f, rolloff_position, -15.f), 10.f); + + // Base smootherstep brings dechroma into the midtones while remaining monotonic and C2. + if (dechroma != 0.f) { + purity_scale *= mad(-dechroma, rolloff, 1.f); + } + + // Blend smootherstep squared and cubed for a later, gentler C2 progression. + if (highlight_saturation != 1.f) { + float highlight_rolloff = rolloff * rolloff * mad(HIGHLIGHT_ROLLOFF_CUBIC_BLEND, rolloff, 1.f - HIGHLIGHT_ROLLOFF_CUBIC_BLEND); + purity_scale *= mad(highlight_saturation - 1.f, highlight_rolloff * HIGHLIGHT_PURITY_STRENGTH, 1.f); + } + } + + float3 contrast_input = renodx::tonemap::psychov::psycho25_ApplyAdaptiveMBPurity( + lms_in, + current_adaptive_state_lms, + purity_scale); + float3 contrast_lms = ApplyAnchoredTonalGrading( + contrast_input, + current_adaptive_state_lms, + current_background_state_lms, + cone_response_exponent, + flare, + 1.f, + 1.f, + highlights, + shadows); + + float3 output_lms = CompressPsychoV25ReferenceScaleHull( + contrast_lms, + contrast_input, + current_adaptive_state_lms, + current_background_state_lms, + target_lms_peak, + source_direction_recovery_strength, + compression, + peak_value); + return renodx::color::bt709::from::LMS(output_lms); +} + +#endif // RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ \ No newline at end of file diff --git a/src/games/elitedangerous/tonemap/psychov25/nrg.hlsli b/src/games/elitedangerous/tonemap/psychov25/nrg.hlsli deleted file mode 100644 index dd86e5fe4..000000000 --- a/src/games/elitedangerous/tonemap/psychov25/nrg.hlsli +++ /dev/null @@ -1,919 +0,0 @@ -#ifndef RENODX_SHADERS_TONEMAP_NRG_HLSL_ -#define RENODX_SHADERS_TONEMAP_NRG_HLSL_ - -#include "../../common.hlsli" -#include "./acc_dkl.hlsli" -#include "./bleaching.hlsli" -#include "./stockman.hlsli" - -namespace renodx { -namespace tonemap { -namespace nrg { - -static const int NRG_BLEACH_MODEL_SCALAR = 0; -static const int NRG_BLEACH_MODEL_PER_CONE = 1; -static const int NRG_TEST5_ENERGY_BT2020_ABS_SUM = 0; -static const int NRG_TEST5_ENERGY_LMS_D65_ABS_SUM = 1; -static const int NRG_TEST5_ENERGY_ACC_A = 2; -static const int NRG_TEST6_CURVE_RH = 0; -static const int NRG_TEST6_CURVE_NR = 1; -// Wider blend to avoid abrupt dark/bright branch flicker around the adaptation anchor. -static const float NRG_TEST6_SIGN_BLEND_WIDTH = 0.08f; -// Test5 target: reach max chroma at 25% RH/Yf-relative progress. -static const float NRG_TEST5_P_WALL = 0.25f; -// CastleCSF uses absolute luminance units (cd/m^2). -// For this test path, scene values are mapped into [min_nits, max_nits] -// where max_nits scales with peak. -static const float NRG_TEST6_CASTLE_MIN_NITS = 0.005f; -static const float NRG_TEST6_CASTLE_BASE_NITS = 100.f; // max_nits when peak == 1 -static const float NRG_TEST6_CASTLE_BACKGROUND_NITS = 5.f; -static const float NRG_TEST6_CASTLE_RHO_CPD = 1.f; -static const float NRG_TEST6_CASTLE_OMEGA_HZ = 0.f; -static const float NRG_TEST6_CASTLE_ECC_DEG = 0.f; -static const float NRG_TEST6_CASTLE_VIS_FIELD_DEG = 0.f; -static const float NRG_TEST6_CASTLE_AREA_DEG2 = 3.14159265f; - -// Anchored Rushton-Henry scalar response for NRGTest4. -// Uses RH availability in relative-drive space, normalized so: -// - y(gray_anchor) = gray_anchor -// - y(infinity) -> peak -float NRGTest4ScalarRushtonHenryToPeak(float x_unit, float peak) { - const float kEps = 1e-6f; - const float kGrayAnchorDefault = 0.18f; - - float p = max(peak, kEps); - float g = min(kGrayAnchorDefault, p * 0.5f); - g = max(g, kEps); - - float relative_drive = max(renodx::math::DivideSafe(max(x_unit, 0.f), g, 0.f), 0.f); - float knee_ratio = max(renodx::math::DivideSafe(p, g, 0.f) - 1.f, kEps); - - float availability = - renodx::color::bleaching::rushton_henry::AvailabilityFromRelativeDrive( - relative_drive, - knee_ratio); - float availability_at_gray = - renodx::color::bleaching::rushton_henry::AvailabilityFromRelativeDrive( - 1.f, - knee_ratio); - - float drive_out = relative_drive * availability; - float drive_out_normalized = - renodx::math::DivideSafe(drive_out, availability_at_gray, 0.f); - - float y = g * drive_out_normalized; - return min(max(y, 0.f), p); -} - -bool IntersectLinearBoundedInterval( - float x0, - float dx, - float min_value, - float max_value, - inout float k_lo, - inout float k_hi) { - const float kSlopeEps = 1e-8f; - if (abs(dx) <= kSlopeEps) { - return x0 >= min_value && x0 <= max_value; - } - - float t0 = renodx::math::DivideSafe(min_value - x0, dx, 0.f); - float t1 = renodx::math::DivideSafe(max_value - x0, dx, 0.f); - float t_min = min(t0, t1); - float t_max = max(t0, t1); - - k_lo = max(k_lo, t_min); - k_hi = min(k_hi, t_max); - return k_hi >= k_lo; -} - -float ComputeAbsSum(float3 v) { - return abs(v.x) + abs(v.y) + abs(v.z); -} - -float NRGTest6PeakWhiteNits(float peak) { - const float kEps = 1e-6f; - float peak_ref = max(peak, kEps); - return max(NRG_TEST6_CASTLE_BASE_NITS * peak_ref, NRG_TEST6_CASTLE_MIN_NITS + kEps); -} - -float3 NRGTest6StimulusNits(float3 bt2020_linear, float peak) { - const float kEps = 1e-6f; - float peak_ref = max(peak, kEps); - float white_nits = NRGTest6PeakWhiteNits(peak_ref); - float3 scene_unit = saturate(bt2020_linear / peak_ref); - return lerp(NRG_TEST6_CASTLE_MIN_NITS.xxx, white_nits.xxx, scene_unit); -} - -float NRGTest6BackgroundYCdM2( - float peak, - float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { - float white_nits = NRGTest6PeakWhiteNits(peak); - return clamp(background_nits, NRG_TEST6_CASTLE_MIN_NITS, white_nits); -} - -float NRGTest6JNDScalarRaw( - float3 bt2020_linear, - float peak, - float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { - const float kEps = 1e-6f; - float peak_ref = max(peak, kEps); - float Y0_cd_m2 = NRGTest6BackgroundYCdM2(peak_ref, background_nits); - - // Match CastleCSFOld's relative-drive convention: - // delta is background-relative LMS contrast, then CastleCSF converts to ACC/DKL internally. - float3 stimulus_nits = NRGTest6StimulusNits(bt2020_linear, peak_ref); - float3 lms_stimulus = renodx::color::lms::from::BT2020(stimulus_nits); - float3 xyz_background = renodx::color::xyz::from::xyY(float3(0.31272f, 0.32903f, max(Y0_cd_m2, 1e-4f))); - float3 lms_background = renodx::color::lms::from::XYZ(xyz_background); - float3 delta_lms_relative = (lms_stimulus - lms_background) / max(abs(lms_background), kEps.xxx); - - float4 energy = renodx::color::castlecsf::CastleCSF_Energy( - delta_lms_relative, - max(Y0_cd_m2, 1e-4f), - NRG_TEST6_CASTLE_RHO_CPD, - NRG_TEST6_CASTLE_OMEGA_HZ, - NRG_TEST6_CASTLE_ECC_DEG, - NRG_TEST6_CASTLE_VIS_FIELD_DEG, - NRG_TEST6_CASTLE_AREA_DEG2); - - return max(energy.w, 0.f); -} - -float NRGTest6SignedAchromaticContrast( - float3 bt2020_linear, - float peak = 1.f, - float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { - const float kEps = 1e-6f; - float peak_ref = max(peak, kEps); - float3 stimulus_nits = NRGTest6StimulusNits(bt2020_linear, peak_ref); - float3 lms_stimulus = renodx::color::lms::from::BT2020(stimulus_nits); - float Y0_cd_m2 = NRGTest6BackgroundYCdM2(peak_ref, background_nits); - float3 xyz_background = renodx::color::xyz::from::xyY(float3(0.31272f, 0.32903f, max(Y0_cd_m2, 1e-4f))); - float3 lms_background = renodx::color::lms::from::XYZ(xyz_background); - - float achromatic_stimulus = lms_stimulus.x + lms_stimulus.y; - float achromatic_background = lms_background.x + lms_background.y; - return renodx::math::DivideSafe( - achromatic_stimulus - achromatic_background, - max(abs(achromatic_background), kEps), - 0.f); -} - -float NRGTest6JNDPeakZeroRaw( - float3 bt2020_linear, - float peak, - float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { - const float kEps = 1e-6f; - float peak_ref = max(peak, kEps); - float Y0_cd_m2 = NRGTest6BackgroundYCdM2(peak_ref, background_nits); - - float3 lms_stimulus = renodx::color::lms::from::BT2020( - NRGTest6StimulusNits(bt2020_linear, peak_ref)); - float3 lms_black = renodx::color::lms::from::BT2020( - NRGTest6StimulusNits(0, peak_ref)); - float3 lms_background = renodx::color::lms::from::XYZ( - renodx::color::xyz::from::xyY(float3(0.31272f, 0.32903f, max(Y0_cd_m2, 1e-4f)))); - - float3 delta_lms_relative = (lms_stimulus - lms_black) / max(abs(lms_background), kEps.xxx); - float4 energy = renodx::color::castlecsf::CastleCSF_Energy( - delta_lms_relative, - max(Y0_cd_m2, 1e-4f), - NRG_TEST6_CASTLE_RHO_CPD, - NRG_TEST6_CASTLE_OMEGA_HZ, - NRG_TEST6_CASTLE_ECC_DEG, - NRG_TEST6_CASTLE_VIS_FIELD_DEG, - NRG_TEST6_CASTLE_AREA_DEG2); - return max(energy.w, 0.f); -} - -void NRGTest6PerceptualDetailBudgetRaw( - float peak, - float background_nits, - out float detail_budget_dark_raw, - out float detail_budget_bright_raw, - out float detail_budget_max_raw) { - const float kEps = 1e-6f; - float peak_ref = max(peak, kEps); - - // Available perceptual range around the adaptation point: - // - dark side: adaptation -> minimum display luminance - // - bright side: adaptation -> peak white - detail_budget_dark_raw = max( - NRGTest6JNDScalarRaw(0, peak_ref, background_nits), - kEps); - detail_budget_bright_raw = max( - NRGTest6JNDScalarRaw(peak_ref.xxx, peak_ref, background_nits), - kEps); - detail_budget_max_raw = max(detail_budget_dark_raw, detail_budget_bright_raw); -} - -float NRGTest6CurveBudgetUnit( - float budget_unit, - int curve_mode = NRG_TEST6_CURVE_RH) { - float x = saturate(budget_unit); - if (curve_mode == NRG_TEST6_CURVE_NR) { - return saturate(renodx::tonemap::NakaRushton( - x, - 1.f, - 0.18f, - 0.18f, - 1.f)); - } - // Default: feed budget-normalized magnitude into the same RH line used by NRGTest4. - return NRGTest4ScalarRushtonHenryToPeak(x, 1.f); -} - -float NRGTest6CurveBudgetUnitAnchored( - float budget_unit, - int curve_mode = NRG_TEST6_CURVE_RH) { - const float kEps = 1e-6f; - float x = saturate(budget_unit); - float y0 = NRGTest6CurveBudgetUnit(0.f, curve_mode); - float y1 = NRGTest6CurveBudgetUnit(1.f, curve_mode); - float y = NRGTest6CurveBudgetUnit(x, curve_mode); - return saturate(renodx::math::DivideSafe(y - y0, max(y1 - y0, kEps), 0.f)); -} - -float3 SolveLineByJNDScalar( - float3 start_bt2020, - float3 end_bt2020, - float peak, - float target_scalar_raw, - out float scalar_out_raw, - float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { - const float kEps = 1e-6f; - const int kIterations = 16; - float peak_ref = max(peak, kEps); - - float scalar_start = NRGTest6JNDPeakZeroRaw(start_bt2020, peak_ref, background_nits); - float scalar_end = NRGTest6JNDPeakZeroRaw(end_bt2020, peak_ref, background_nits); - bool increasing = scalar_end >= scalar_start; - - if ((increasing && target_scalar_raw <= scalar_start + kEps) || (!increasing && target_scalar_raw >= scalar_start - kEps)) { - scalar_out_raw = scalar_start; - return start_bt2020; - } - if ((increasing && target_scalar_raw >= scalar_end - kEps) || (!increasing && target_scalar_raw <= scalar_end + kEps)) { - scalar_out_raw = scalar_end; - return end_bt2020; - } - - float lo = 0.f; - float hi = 1.f; - - [unroll] - for (int i = 0; i < kIterations; ++i) { - float mid = 0.5f * (lo + hi); - float3 sample_bt2020 = lerp(start_bt2020, end_bt2020, mid); - float scalar_sample = NRGTest6JNDPeakZeroRaw(sample_bt2020, peak_ref, background_nits); - if ((increasing && scalar_sample < target_scalar_raw) || (!increasing && scalar_sample > target_scalar_raw)) { - lo = mid; - } else { - hi = mid; - } - } - - float t = 0.5f * (lo + hi); - float3 bt2020_out = lerp(start_bt2020, end_bt2020, t); - scalar_out_raw = NRGTest6JNDPeakZeroRaw(bt2020_out, peak_ref, background_nits); - return bt2020_out; -} - -float3 BlendChromaAndWhiteSpillJND( - float3 bt2020_chroma, - float3 bt2020_chroma_max, - float peak, - float scalar_output_raw, - float scalar_chroma_max, - float scalar_white_raw, - float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS) { - float scalar_final_raw; - float3 bt2020_spill = SolveLineByJNDScalar( - bt2020_chroma_max, - peak.xxx, - peak, - scalar_output_raw, - scalar_final_raw, - background_nits); - - float wall_width = max(0.02f * scalar_white_raw, 1e-6f); - float wall_mix = smoothstep( - scalar_chroma_max - wall_width, - scalar_chroma_max + wall_width, - scalar_output_raw); - - return lerp(bt2020_chroma, bt2020_spill, wall_mix); -} - -// Find first in-gamut point on the line from bt2020_input toward neutral white (peak,peak,peak): -// p(t) = bt2020_input + t * (white - bt2020_input), t in [0,1] -// We return t_lo (entry point from out-of-gamut side), which is guaranteed to exist -// because t=1 is always white and in gamut. -float SolveBT2020BoundaryTowardWhite( - float3 bt2020_input, - float peak, - out float3 out_bt2020) { - float3 white = peak.xxx; - float3 delta = white - bt2020_input; - - float t_lo = 0.f; - float t_hi = 1.f; - if (!IntersectLinearBoundedInterval(bt2020_input.x, delta.x, 0.f, peak, t_lo, t_hi) || !IntersectLinearBoundedInterval(bt2020_input.y, delta.y, 0.f, peak, t_lo, t_hi) || !IntersectLinearBoundedInterval(bt2020_input.z, delta.z, 0.f, peak, t_lo, t_hi)) { - out_bt2020 = white; - return 1.f; - } - - out_bt2020 = bt2020_input + delta * t_lo; - return t_lo; -} - -float3 ComputeBT2020ChromaMaxFromInput(float3 bt2020_linear, float peak_ref, float kEps) { - float3 bt2020_chroma_max; - - float max_channel = max(max(bt2020_linear.x, bt2020_linear.y), bt2020_linear.z); - bool use_bt2020_hue_boundary = all(bt2020_linear >= 0) && max_channel > kEps; - if (use_bt2020_hue_boundary) { - float3 bt2020_hue_unit = bt2020_linear / max_channel; - bt2020_chroma_max = bt2020_hue_unit * peak_ref; - } else { - // Signed/out-of-gamut input: - // preserve usable hue direction from positive BT.2020 components. - float3 bt2020_positive = max(bt2020_linear, 0); - float positive_max = max(max(bt2020_positive.x, bt2020_positive.y), bt2020_positive.z); - if (positive_max > kEps) { - float3 bt2020_hue_unit = bt2020_positive / positive_max; - bt2020_chroma_max = bt2020_hue_unit * peak_ref; - } else { - SolveBT2020BoundaryTowardWhite( - bt2020_linear, - peak_ref, - bt2020_chroma_max); - } - } - - return bt2020_chroma_max; -} - -// Solve t in out = lerp(bt2020_start, peak_white, t) such that -// abs-sum energy in BT.2020 channel space matches target_scalar_raw. -float3 SolveWhiteSpillByEnergy( - float3 bt2020_start, - float peak, - float target_scalar_raw, - out float scalar_out_raw) { - const float kEps = 1e-6f; - - float scalar_start = ComputeAbsSum(bt2020_start); - - float3 bt2020_white = peak.xxx; - float scalar_white = ComputeAbsSum(bt2020_white); - - if (target_scalar_raw <= scalar_start + kEps) { - scalar_out_raw = scalar_start; - return bt2020_start; - } - if (target_scalar_raw >= scalar_white - kEps) { - scalar_out_raw = scalar_white; - return bt2020_white; - } - - float t = saturate(renodx::math::DivideSafe( - target_scalar_raw - scalar_start, - scalar_white - scalar_start, - 0.f)); - float3 bt2020_out = lerp(bt2020_start, bt2020_white, t); - scalar_out_raw = ComputeAbsSum(bt2020_out); - return bt2020_out; -} - -// Smooth blend across the chroma wall to avoid a visible derivative kink -// at the handoff between \"scale-to-max-chroma\" and \"spill-to-white\". -float3 BlendChromaAndWhiteSpill( - float3 bt2020_chroma, - float3 bt2020_chroma_max, - float peak, - float scalar_output_raw, - float scalar_chroma_max) { - float scalar_final_raw; - float3 bt2020_spill = SolveWhiteSpillByEnergy( - bt2020_chroma_max, - peak, - scalar_output_raw, - scalar_final_raw); - - float scalar_white = 3.f * max(peak, 1e-6f); - float wall_width = max(0.02f * scalar_white, 1e-6f); - float wall_mix = smoothstep( - scalar_chroma_max - wall_width, - scalar_chroma_max + wall_width, - scalar_output_raw); - - return lerp(bt2020_chroma, bt2020_spill, wall_mix); -} - -float3 FastInputLMSEnergyGray(float3 bt709_linear) { - float3 lms = renodx::color::lms::from::BT709(bt709_linear); - float3 lms_white = renodx::color::lms::from::WhiteD65(1.f); - - float3 lms_norm = lms / lms_white; - float scalar_raw = abs(lms_norm.x) + abs(lms_norm.y) + abs(lms_norm.z); - float scalar_input = scalar_raw / 3.f; - return scalar_input.xxx; -} - -float3 NeutwoBT709WhiteForEnergy(float3 bt709_linear, float peak = 1.f) { - const float kEps = 1e-6f; - const float kType7WhiteUnits = 3.f; - const float kChromaCurve = 1.5f; - - float3 lms = renodx::color::lms::from::BT709(bt709_linear); - float3 lms_white = renodx::color::lms::from::WhiteD65(1.f); - - float3 lms_norm_input = lms / lms_white; - float scalar_input_raw = abs(lms_norm_input.x) + abs(lms_norm_input.y) + abs(lms_norm_input.z); - float scalar_input = scalar_input_raw / kType7WhiteUnits; - - float peak_ref = max(peak, kEps); - float scalar_peak = peak_ref; - float scalar_output = renodx::tonemap::Neutwo(scalar_input, scalar_peak); - - float3 lms_d65 = lms / renodx::color::lms::from::WhiteD65(1.f); - float3 acc_input = renodx::color::stockman::acc::from::LMSD65(lms_d65); - float t = saturate(scalar_output / scalar_peak); - float chroma_scale = 1.f - pow(t, kChromaCurve); - float2 acc_chroma_out = acc_input.yz * chroma_scale; - - float3 lms_white_target_d65 = scalar_output.xxx; - float3 acc_white = renodx::color::stockman::acc::from::LMSD65(lms_white_target_d65); - - float3 acc_out = float3(acc_white.x, acc_chroma_out.x, acc_chroma_out.y); - float3 lms_out_d65 = renodx::color::stockman::acc::to::LMSD65(acc_out); - float3 lms_out = lms_out_d65 * renodx::color::lms::from::WhiteD65(1.f); - - float3 lms_norm_scalar = lms_out / lms_white; - float scalar_out_raw = abs(lms_norm_scalar.x) + abs(lms_norm_scalar.y) + abs(lms_norm_scalar.z); - float scalar_target_raw = scalar_output * kType7WhiteUnits; - float scalar_match_scale = scalar_target_raw / max(scalar_out_raw, kEps); - lms_out *= scalar_match_scale; - - return renodx::color::bt709::from::LMS(lms_out); -} - -float3 NRGTest2(float3 bt709_linear, float peak = 1.f) { - const float kEps = 1e-6f; - const float kUnits = 1.f; - const float strength = 0.18f * peak; - float peak_ref = max(peak, kEps); - - float3 lms = renodx::color::lms::from::BT709(bt709_linear); - float3 lms_white = renodx::color::lms::from::WhiteE(1.f); - - float3 lms_norm_input = lms / lms_white; - float scalar_raw_input = lms_norm_input.x + lms_norm_input.y + lms_norm_input.z; - float scalar_input = scalar_raw_input / kUnits; - - float3 lms_peak = lms_white * peak_ref; - float3 lms_norm_peak = lms_peak / lms_white; - float scalar_raw_peak = lms_norm_peak.x + lms_norm_peak.y + lms_norm_peak.z; - float scalar_peak = scalar_raw_peak / kUnits; - float scalar_output = renodx::tonemap::Neutwo(scalar_input, scalar_peak); - - float scalar_input_raw = scalar_input * kUnits; - float scalar_output_raw = scalar_output * kUnits; - - float3 lms_gray = lms_white * strength; - float3 lms_gray_in = lms_gray * scalar_input_raw; - float3 lms_gray_out = lms_gray * scalar_output_raw; - float3 lms_chroma = lms - lms_gray_in; - float available_white = saturate(renodx::math::DivideSafe( - scalar_peak - scalar_output, - scalar_peak, - 0.f)); - - float3 lms_out = lms_gray_out + lms_chroma * available_white; - float3 lms_norm_out = lms_out / lms_white; - float scalar_out_raw = lms_norm_out.x + lms_norm_out.y + lms_norm_out.z; - lms_out *= renodx::math::DivideSafe(scalar_output_raw, scalar_out_raw, 0.f); - - lms_norm_out = lms_out / lms_white; - scalar_out_raw = lms_norm_out.x + lms_norm_out.y + lms_norm_out.z; - lms_out *= renodx::math::DivideSafe(scalar_output_raw, scalar_out_raw, 0.f); - - float3 bt709_out = renodx::color::bt709::from::LMS(lms_out); - float3 bt2020_out = renodx::color::bt2020::from::BT709(bt709_out); - bt2020_out = clamp(bt2020_out, 0.f, peak_ref.xxx); - return renodx::color::bt709::from::BT2020(bt2020_out); -} - -float3 NRGTest3BT2020(float3 bt2020_linear, float peak = 1.f) { - const float kEps = 1e-6f; - const float kScalarWhiteUnits = 3.f; // BT.2020 abs-sum: white@1 = 3, white@peak = 3*peak. - float peak_ref = max(peak, kEps); - - // Scalar units in BT.2020: - // white@1 = 3, peak(8) = 24. - float scalar_input_raw = ComputeAbsSum(bt2020_linear); - float scalar_input_unit = scalar_input_raw / kScalarWhiteUnits; - float scalar_output_unit = renodx::tonemap::NakaRushton( - scalar_input_unit, - peak_ref, - 0.18f, - 0.18f, - 1.f); - float scalar_output_raw = scalar_output_unit * kScalarWhiteUnits; - float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); - - float scalar_chroma_max = ComputeAbsSum(bt2020_chroma_max); - if (scalar_chroma_max <= kEps) { - // Degenerate case: boundary is black. Move on black->white by scalar budget. - float scalar_final_raw; - return SolveWhiteSpillByEnergy( - 0, - peak_ref, - scalar_output_raw, - scalar_final_raw); - } - - // Chroma budget does NOT pass through Neutwo; only E_in does. - float scalar_chroma = min(scalar_output_raw, scalar_chroma_max); - float chroma_scale = renodx::math::DivideSafe( - scalar_chroma, - scalar_chroma_max, - 0.f); - float3 bt2020_chroma = bt2020_chroma_max * chroma_scale; - return BlendChromaAndWhiteSpill( - bt2020_chroma, - bt2020_chroma_max, - peak_ref, - scalar_output_raw, - scalar_chroma_max); -} - -float3 NRGTest3(float3 bt709_linear, float peak = 1.f) { - float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); - float3 bt2020_out = NRGTest3BT2020(bt2020_linear, peak); - return renodx::color::bt709::from::BT2020(bt2020_out); -} - -float3 NRGTest4BT2020(float3 bt2020_linear, float peak = 1.f) { - const float kEps = 1e-6f; - const float kScalarWhiteUnits = 3.f; // BT.2020 abs-sum: white@1 = 3, white@peak = 3*peak. - float peak_ref = max(peak, kEps); - - // Scalar units in BT.2020: - // white@1 = 3, peak(8) = 24. - float scalar_input_raw = ComputeAbsSum(bt2020_linear); - float scalar_input_unit = scalar_input_raw / kScalarWhiteUnits; - float scalar_output_unit = NRGTest4ScalarRushtonHenryToPeak(scalar_input_unit, peak_ref); - float scalar_output_raw = scalar_output_unit * kScalarWhiteUnits; - float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); - - float scalar_chroma_max = ComputeAbsSum(bt2020_chroma_max); - if (scalar_chroma_max <= kEps) { - // Degenerate case: boundary is black. Move on black->white by scalar budget. - float scalar_final_raw; - return SolveWhiteSpillByEnergy( - 0, - peak_ref, - scalar_output_raw, - scalar_final_raw); - } - - // Chroma budget does NOT pass through Rushton-Henry; only E_in does. - float scalar_chroma = min(scalar_output_raw, scalar_chroma_max); - float chroma_scale = renodx::math::DivideSafe( - scalar_chroma, - scalar_chroma_max, - 0.f); - float3 bt2020_chroma = bt2020_chroma_max * chroma_scale; - return BlendChromaAndWhiteSpill( - bt2020_chroma, - bt2020_chroma_max, - peak_ref, - scalar_output_raw, - scalar_chroma_max); -} - -float3 NRGTest4(float3 bt709_linear, float peak = 1.f) { - float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); - float3 bt2020_out = NRGTest4BT2020(bt2020_linear, peak); - return renodx::color::bt709::from::BT2020(bt2020_out); -} - -float NRGTest5ScalarInputUnit( - float3 bt2020_linear, - int energy_mode = NRG_TEST5_ENERGY_ACC_A) { - const float kEps = 1e-6f; - const float kScalarWhiteUnits = 3.f; - - float3 lms_d65 = renodx::color::lms::from::BT2020(bt2020_linear) / max(renodx::color::lms::from::WhiteD65(1.f), 1e-6f.xxx); - if (energy_mode == NRG_TEST5_ENERGY_ACC_A) { - float3 acc = renodx::color::stockman::acc::from::LMSD65(lms_d65); - float acc_white = max(abs(renodx::color::stockman::acc::from::LMSD65(float3(1, 1, 1)).x), kEps); - return abs(acc.x) / acc_white; - } - - if (energy_mode == NRG_TEST5_ENERGY_LMS_D65_ABS_SUM) { - return ComputeAbsSum(lms_d65) / kScalarWhiteUnits; - } - - return ComputeAbsSum(bt2020_linear) / kScalarWhiteUnits; -} - -float NRGTest7ScalarAccARaw( - float3 bt2020_linear, - float peak = 1.f) { - const float kEps = 1e-6f; - const float kScalarWhiteUnits = 3.f; - float peak_ref = max(peak, kEps); - - float scalar_unit = NRGTest5ScalarInputUnit( - max(bt2020_linear, 0), - NRG_TEST5_ENERGY_ACC_A); - return scalar_unit * kScalarWhiteUnits; -} - -float3 NRGTest7SolveWhiteSpillByScalarAccA( - float3 bt2020_start, - float peak, - float target_scalar_raw, - out float scalar_out_raw) { - const float kEps = 1e-6f; - const int kIterations = 16; - const float kScalarWhiteUnits = 3.f; - float peak_ref = max(peak, kEps); - - float3 bt2020_white = peak_ref.xxx; - float scalar_start = NRGTest7ScalarAccARaw(bt2020_start, peak_ref); - float scalar_white = kScalarWhiteUnits * peak_ref; - float scalar_target = clamp(target_scalar_raw, scalar_start, scalar_white); - - if (scalar_target <= scalar_start + kEps) { - scalar_out_raw = scalar_start; - return bt2020_start; - } - if (scalar_target >= scalar_white - kEps) { - scalar_out_raw = scalar_white; - return bt2020_white; - } - - float lo = 0.f; - float hi = 1.f; - [unroll] - for (int i = 0; i < kIterations; ++i) { - float mid = 0.5f * (lo + hi); - float3 sample = lerp(bt2020_start, bt2020_white, mid); - float scalar_mid = NRGTest7ScalarAccARaw(sample, peak_ref); - if (scalar_mid < scalar_target) { - lo = mid; - } else { - hi = mid; - } - } - - float t = 0.5f * (lo + hi); - float3 out_bt2020 = lerp(bt2020_start, bt2020_white, t); - scalar_out_raw = NRGTest7ScalarAccARaw(out_bt2020, peak_ref); - return out_bt2020; -} - -float3 NRGTest7BlendChromaAndWhiteSpillNeutwoClipHueWall( - float3 bt2020_chroma, - float3 bt2020_chroma_max, - float peak, - float scalar_output_raw, - float scalar_chroma_max, - float start_ratio = 1.f, - float shape = 1.f) { - const float kEps = 1e-6f; - const float kScalarWhiteUnits = 3.f; - float peak_ref = max(peak, kEps); - float scalar_white_raw = kScalarWhiteUnits * peak_ref; - - float scalar_start = scalar_chroma_max * saturate(start_ratio); - float scalar_overdrive = max(scalar_output_raw - scalar_start, 0.f); - float scalar_headroom = max(scalar_white_raw - scalar_start, kEps); - float scalar_overdrive_unit = scalar_overdrive / scalar_headroom; - - // Per-hue clip from wall capacity in ACC-A scalar units. - // low wall -> clip near 1 (faster white), high wall -> clip near 2 (slower white) - float clip_hue = 1.f + saturate(renodx::math::DivideSafe(scalar_chroma_max, max(scalar_white_raw, kEps), 0.f)); - - float white_mix = saturate(renodx::tonemap::Neutwo( - scalar_overdrive_unit, - 1.f, - clip_hue)); - if (abs(shape - 1.f) > 1e-6f) { - white_mix = pow(max(white_mix, 0.f), max(shape, 1e-6f)); - } - - float scalar_spill_raw; - float3 bt2020_spill = NRGTest7SolveWhiteSpillByScalarAccA( - bt2020_chroma_max, - peak_ref, - scalar_output_raw, - scalar_spill_raw); - return lerp(bt2020_chroma, bt2020_spill, white_mix); -} - -float3 NRGTest5BT2020( - float3 bt2020_linear, - float peak = 1.f, - int energy_mode = NRG_TEST5_ENERGY_ACC_A) { - const float kEps = 1e-6f; - const float kScalarWhiteUnits = 3.f; // BT.2020 abs-sum: white@1 = 3, white@peak = 3*peak. - float peak_ref = max(peak, kEps); - - // Test5 keeps Test4's robust hue geometry, but allows alternate scalar energy drives. - float scalar_input_unit = NRGTest5ScalarInputUnit(bt2020_linear, energy_mode); - float scalar_output_unit = NRGTest4ScalarRushtonHenryToPeak(scalar_input_unit, peak_ref); - float scalar_output_raw = scalar_output_unit * kScalarWhiteUnits; - float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); - - float scalar_chroma_max = ComputeAbsSum(bt2020_chroma_max); - if (scalar_chroma_max <= kEps) { - // Degenerate hue: move on neutral axis by RH scalar percent. - return (scalar_output_raw / kScalarWhiteUnits).xxx; - } - - float scalar_white_raw = kScalarWhiteUnits * peak_ref; - float p = saturate(renodx::math::DivideSafe( - scalar_output_raw, - scalar_white_raw, - 0.f)); - - float p_wall = clamp(NRG_TEST5_P_WALL, 1e-4f, 0.9999f); - if (p <= p_wall) { - // Stage 1: black -> max chroma (at p_wall). - float chroma_t = saturate(renodx::math::DivideSafe(p, p_wall, 0.f)); - return bt2020_chroma_max * chroma_t; - } - - // Stage 2: max chroma -> white. Max chroma is only present at p == p_wall. - float white_t = saturate(renodx::math::DivideSafe( - p - p_wall, - 1.f - p_wall, - 0.f)); - return lerp(bt2020_chroma_max, peak_ref.xxx, white_t); -} - -float3 NRGTest5( - float3 bt709_linear, - float peak = 1.f, - int energy_mode = NRG_TEST5_ENERGY_ACC_A) { - float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); - float3 bt2020_out = NRGTest5BT2020(bt2020_linear, peak, energy_mode); - return renodx::color::bt709::from::BT2020(bt2020_out); -} - -float3 NRGTest6BT2020( - float3 bt2020_linear, - float peak = 1.f, - float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS, - int curve_mode = NRG_TEST6_CURVE_RH) { - const float kEps = 1e-6f; - const float kScalarWhiteUnits = 3.f; // Virtual spill pressure only; final targets stay <= white JND. - float peak_ref = max(peak, kEps); - // Test4 geometry, but scalar is total JND from black->color normalized by black->peak. - float scalar_peak_raw = max( - NRGTest6JNDPeakZeroRaw(peak_ref.xxx, peak_ref, background_nits), - kEps); - float scalar_input_raw = NRGTest6JNDPeakZeroRaw(bt2020_linear, peak_ref, background_nits); - float scalar_input_unit = scalar_input_raw * peak_ref / scalar_peak_raw; - float scalar_output_unit = curve_mode == NRG_TEST6_CURVE_NR - ? renodx::tonemap::NakaRushton(scalar_input_unit, peak_ref, 0.18f, 0.18f, 1.f) - : NRGTest4ScalarRushtonHenryToPeak(scalar_input_unit, peak_ref); - float scalar_output_raw = scalar_output_unit * scalar_peak_raw / peak_ref; - float scalar_white_raw = scalar_peak_raw; - - float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); - float scalar_chroma_max = NRGTest6JNDPeakZeroRaw(bt2020_chroma_max, peak_ref, background_nits); - if (scalar_chroma_max <= kEps) { - float scalar_final_raw; - return SolveLineByJNDScalar( - 0, - peak_ref.xxx, - peak_ref, - scalar_output_raw, - scalar_final_raw, - background_nits); - } - - // Apply extra spill pressure in a virtual scalar domain, but remap the result - // back into the physically reachable JND interval [scalar_chroma_max, scalar_peak_raw]. - float scalar_headroom = max(scalar_peak_raw - scalar_chroma_max, 0.f); - if (scalar_headroom > kEps) { - float scalar_output_virtual = scalar_output_raw * kScalarWhiteUnits; - float scalar_overflow = max(scalar_output_virtual - scalar_chroma_max, 0.f); - if (scalar_overflow > kEps) { - float scalar_overflow_norm = saturate(renodx::math::DivideSafe( - scalar_overflow, - max(scalar_peak_raw * (kScalarWhiteUnits - 1.f), kEps), - 0.f)); - float scalar_spill_target = lerp(scalar_chroma_max, scalar_peak_raw, scalar_overflow_norm); - scalar_output_raw = max(scalar_output_raw, scalar_spill_target); - } - } - - float scalar_chroma = min(scalar_output_raw, scalar_chroma_max); - float scalar_chroma_raw; - float3 bt2020_chroma = SolveLineByJNDScalar( - 0, - bt2020_chroma_max, - peak_ref, - scalar_chroma, - scalar_chroma_raw, - background_nits); - - return BlendChromaAndWhiteSpillJND( - bt2020_chroma, - bt2020_chroma_max, - peak_ref, - scalar_output_raw, - scalar_chroma_max, - scalar_white_raw, - background_nits); -} - -float3 NRGTest6( - float3 bt709_linear, - float peak = 1.f, - float background_nits = NRG_TEST6_CASTLE_BACKGROUND_NITS, - int curve_mode = NRG_TEST6_CURVE_RH) { - float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); - float3 bt2020_out = NRGTest6BT2020(bt2020_linear, peak, background_nits, curve_mode); - return renodx::color::bt709::from::BT2020(bt2020_out); -} - -float3 NRGTest7HueClipBT2020(float3 bt2020_linear, float peak = 1.f) { - const float kEps = 1e-6f; - const float kScalarWhiteUnits = 3.f; - float peak_ref = max(peak, kEps); - - // ACC-A scalar drive -> Neutwo white curve. - float scalar_input_unit = NRGTest5ScalarInputUnit( - max(bt2020_linear, 0), - NRG_TEST5_ENERGY_ACC_A); - float scalar_output_unit = renodx::tonemap::Neutwo( - max(scalar_input_unit, 0.f), - peak_ref); - float scalar_output_raw = scalar_output_unit * kScalarWhiteUnits; - - // Max-hue anchor in BT.2020, then transition toward white. - float3 bt2020_chroma_max = ComputeBT2020ChromaMaxFromInput(bt2020_linear, peak_ref, kEps); - float scalar_chroma_max = NRGTest7ScalarAccARaw(bt2020_chroma_max, peak_ref); - if (scalar_chroma_max <= kEps) { - float scalar_final_raw; - return NRGTest7SolveWhiteSpillByScalarAccA( - 0, - peak_ref, - scalar_output_raw, - scalar_final_raw); - } - - float scalar_chroma = min(scalar_output_raw, scalar_chroma_max); - float chroma_scale = renodx::math::DivideSafe( - scalar_chroma, - scalar_chroma_max, - 0.f); - float3 bt2020_chroma = bt2020_chroma_max * chroma_scale; - - return NRGTest7BlendChromaAndWhiteSpillNeutwoClipHueWall( - bt2020_chroma, - bt2020_chroma_max, - peak_ref, - scalar_output_raw, - scalar_chroma_max, - 1.f, - 1.f); -} - -float3 NRGTest7HueClip(float3 bt709_linear, float peak = 1.f) { - float3 bt2020_linear = renodx::color::bt2020::from::BT709(bt709_linear); - float3 bt2020_out = NRGTest7HueClipBT2020(bt2020_linear, peak); - return renodx::color::bt709::from::BT2020(bt2020_out); -} - -float3 BT709TEST7(float3 bt709_linear, - float display_peak = 1.f, - int mode = NRG_BLEACH_MODEL_SCALAR) { - if (mode == NRG_BLEACH_MODEL_PER_CONE) { - return NeutwoBT709WhiteForEnergy(bt709_linear, display_peak); - } - return FastInputLMSEnergyGray(bt709_linear); -} - -float3 BT2020TEST7(float3 bt2020_linear, - float display_peak_bt2020 = 1.f, - int mode = NRG_BLEACH_MODEL_SCALAR) { - float3 bt709 = renodx::color::bt709::from::BT2020(bt2020_linear); - float3 out_bt709 = BT709TEST7(bt709, display_peak_bt2020, mode); - return renodx::color::bt2020::from::BT709(out_bt709); -} - -} // namespace nrg -} // namespace tonemap -} // namespace renodx - -#endif // RENODX_SHADERS_TONEMAP_NRG_HLSL_ diff --git a/src/games/elitedangerous/tonemap/psychov25/stockman.hlsli b/src/games/elitedangerous/tonemap/psychov25/stockman.hlsli deleted file mode 100644 index 283cf2c5b..000000000 --- a/src/games/elitedangerous/tonemap/psychov25/stockman.hlsli +++ /dev/null @@ -1,112 +0,0 @@ -#ifndef SRC_SHADERS_COLOR_STOCKMAN_HLSL_ -#define SRC_SHADERS_COLOR_STOCKMAN_HLSL_ - -#include "../../common.hlsli" - -// Deprecated (use renodx::color::lms::* directly) - -namespace renodx { -namespace color { -namespace bt709 { -namespace from { - -float3 StockmanDKL(float3 dkl) { - // Modified Stockman & Sharpe for LCD LED - float3x3 XYZ_TO_LMS_WUERGER_2020 = float3x3( - 0.187596268556126, 0.585168649077728, -0.026384263306304, - -0.133397430663221, 0.405505777260049, 0.034502127690364, - 0.000244379021663, -0.000542995890619, 0.019406849066323); - - // Manually recomputed from CIE 1931 XYZ 1nm to Stockman 2deg 1nm 8dp with MB2 Weights - float3x3 XYZ_TO_LMS_2006 = float3x3( - 0.185082982238733f, 0.584081279463687f, -0.0240722415044404f, - -0.134433056469973f, 0.405752392775348f, 0.0358252602217631f, - 0.000789456671966863f, -0.000912281325916184f, 0.0198490812339463f); - - float3x3 XYZ_FROM_LMS = renodx::math::Invert3x3(XYZ_TO_LMS_2006); - - // CIE 1931 2 degree standard observer - float2 WHITE_POINT_D65 = float2(0.31272, 0.32903); - float3 D65_XYZ = renodx::color::xyz::from::xyY(float3(WHITE_POINT_D65, 1.f)); - float3 LMS_WHITE = mul(XYZ_TO_LMS_2006, D65_XYZ); - - float mc1 = LMS_WHITE.x / LMS_WHITE.y; - float mc2 = (LMS_WHITE.x + LMS_WHITE.y) / LMS_WHITE.z; - - // actual ACC color space (DKL-like / ACC) - float3x3 LMS_TO_DKL_D65 = float3x3( - 1, 1, 0, - 1, -mc1, 0, - -1, -1, mc2); - - float3x3 LMS_FROM_DKL_D65 = renodx::math::Invert3x3(LMS_TO_DKL_D65); - - float3x3 RGB_TO_DKL_D65 = mul(LMS_TO_DKL_D65, XYZ_TO_LMS_2006); - float3x3 DKL_D65_TO_RGB = renodx::math::Invert3x3(RGB_TO_DKL_D65); - - float3 lms_color = mul(LMS_FROM_DKL_D65, dkl); - - float3 lms_background = mul(XYZ_TO_LMS_2006, renodx::color::xyz::from::xyY(float3(WHITE_POINT_D65, 1.00f))); - - lms_background = 0; // skip for now - float3 lms_final = lms_color + lms_background; - - float3 xyz = mul(XYZ_FROM_LMS, lms_final); - - float3 bt709 = renodx::color::bt709::from::XYZ(xyz); - return bt709; -} -} // namespace from -} // namespace bt709 - -namespace stockmandkl { -namespace from { -float3 BT709(float3 bt709) { - // Modified Stockman & Sharpe for LCD LED - float3x3 XYZ_TO_LMS_WUERGER_2020 = float3x3( - 0.187596268556126, 0.585168649077728, -0.026384263306304, - -0.133397430663221, 0.405505777260049, 0.034502127690364, - 0.000244379021663, -0.000542995890619, 0.019406849066323); - - // Manually recomputed from CIE 1931 XYZ 1nm to Stockman 2deg 1nm 8dp with MB2 Weights - float3x3 XYZ_TO_LMS_2006 = float3x3( - 0.185082982238733f, 0.584081279463687f, -0.0240722415044404f, - -0.134433056469973f, 0.405752392775348f, 0.0358252602217631f, - 0.000789456671966863f, -0.000912281325916184f, 0.0198490812339463f); - - float3x3 XYZ_FROM_LMS = renodx::math::Invert3x3(XYZ_TO_LMS_2006); - - // CIE 1931 2 degree standard observer - float2 WHITE_POINT_D65 = float2(0.31272, 0.32903); - float3 D65_XYZ = renodx::color::xyz::from::xyY(float3(WHITE_POINT_D65, 1.f)); - float3 LMS_WHITE = mul(XYZ_TO_LMS_2006, D65_XYZ); - - float mc1 = LMS_WHITE.x / LMS_WHITE.y; - float mc2 = (LMS_WHITE.x + LMS_WHITE.y) / LMS_WHITE.z; - - // actual ACC color space (DKL-like / ACC) - float3x3 LMS_TO_DKL_D65 = float3x3( - 1, 1, 0, - 1, -mc1, 0, - -1, -1, mc2); - - float3x3 LMS_FROM_DKL_D65 = renodx::math::Invert3x3(LMS_TO_DKL_D65); - float3 xyz = renodx::color::xyz::from::BT709(bt709); - float3 lms_input = mul(XYZ_TO_LMS_2006, xyz); - float3 dkl_input = mul(LMS_TO_DKL_D65, lms_input); - - float3 lms_background = mul(XYZ_TO_LMS_2006, renodx::color::xyz::from::xyY(float3(WHITE_POINT_D65, 1.00f))); - - lms_background = 0; // skip for now - float3 delta = lms_input - lms_background; - - float3 dkl = mul(LMS_TO_DKL_D65, delta); - - return dkl; -} -} // namespace from -} // namespace stockmandkl - -} // namespace color -} // namespace renodx -#endif // SRC_SHADERS_COLOR_STOCKMAN_HLSL_ \ No newline at end of file diff --git a/src/games/elitedangerous/tonemap/psychov25/test25.hlsli b/src/games/elitedangerous/tonemap/psychov25/test25.hlsli deleted file mode 100644 index ff5d62b3a..000000000 --- a/src/games/elitedangerous/tonemap/psychov25/test25.hlsli +++ /dev/null @@ -1,4085 +0,0 @@ -#ifndef RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ -#define RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ - -#include "../../common.hlsli" -#include "./nrg.hlsli" - -/* - * Copyright (C) 2026 Carlos Lopez - * SPDX-License-Identifier: MIT - */ - -namespace renodx { -namespace tonemap { -namespace psychov { - -// Psycho25 current implementation -// ------------------------------- -// 1. Test24 grading and adaptive-MB purity are retained. -// 2. Anchor-matched per-cone contrast uses sign-preserving powers, retaining -// signed cone ratios through authored hue and device-hull work. The -// compression-derived power encodes a cone-response state whose adapted -// origin is exactly one. Encoded-response power acts before the rational -// shoulder. -// 3. Per-channel compression defines the raw adaptive-MB hue shift. -// 4. Graph hue authoring uses the numerical sextant peak-search and local -// graph-inversion solve at 50% amplitude. Fast60 is the lower-cost direct -// angular midpoint between the source and raw per-channel-compressed -// adaptive-MB directions. Cone-axis pins remain zeros because the raw -// per-channel hue shift itself is zero on those axes. -// 5. The actual-peak compressed adaptive-MB radius and carried achromatic -// scale are retained while only the direction is changed. -// 6. With gamut compression disabled, the retained per-cone LMS rolloff is the -// output shoulder and supplies the compression path toward adapted white. -// 7. With either target-plane class enabled, per-cone output compression is -// bypassed. -// The actual-peak per-cone result supplies adaptive-MB magnitude and radius. -// A separate compressed direction, whose neutral endpoint is scaled by -// `guidance_peak_scale`, supplies the hue trajectory guide. -// Normalization discards carried scale while preserving the physical radius -// and guided direction without applying either per-cone curve as the final -// output compressor. -// 8. The trajectory-guided adaptive-MB direction supplies one device-hull -// ray. Primary enforcement uses the selected target RGB lower planes; peak -// enforcement uses its upper planes. The two plane classes are independent. -// With peak enforcement disabled, output follows the authored scalar Yf -// after any requested primary correction. Linear BT.709 return values may -// be negative when they represent valid colors inside a wider selected -// target. -// 9. Selected-target lower-plane feasibility is solved against a same-hue -// reference radius no smaller than the current physical trajectory or its -// uncompressed post-contrast source. A C1 radial shoulder begins at 90% of -// the selected-target lower-plane boundary instead of activating only after -// a channel becomes negative. Reusing that scale over the outer trajectory -// preserves its inward-to-white gradient instead of projecting every -// outside point onto the same gamut boundary. The scale releases smoothly -// toward the physical path near neutral so blue can keep gaining channel -// value without turning gray, with a smooth current-trajectory containment -// cap for signed inputs. This is a direction constraint, not a second -// output compression curve. -// 10. Reference and Reduced Max-White smoothly turn the authored direction -// back toward the pre-contrast source direction as the physical radius -// collapses. They do not retain a nonzero radius: chromatic highlights can -// become lighter and converge on white without first rotating through an -// unrelated hue. -// 11. Reference2 is an experimental Graph-authoritative variant of Reference. -// It retains the six-section direction without the post-Graph source- -// direction recovery or Reference's same-Yf radial contraction. After the -// scalar upper-plane shoulder, lower-plane pressure lifts the complete -// selected-target RGB result smoothly toward peak D65 white. Quadratic -// pressure moves an outside trajectory progressively inward instead of -// flattening it onto a target wall without creating an aggressive Yf hump -// at first contact. The result is then reprojected onto the Graph- -// authored adaptive-MB hue while retaining its raised Yf and reduced radius. -// The physical per-cone radius still converges to peak D65 white. -// 12. Linear MB Pullback is a diagnostic lower-plane mode. It retains the -// Graph/Fast60-authored adaptive-MB direction and actual-peak radius while -// feasible, then linearly reduces only that radius to the first selected- -// target lower-plane intersection. It has no custom reference radius, -// shoulder, neutral release, or source-direction recovery. -// 13. Target RGB Clip is a literal comparison path. It runs the same physical -// per-cone and authored-hue result without target-hull mapping, transforms -// it to the selected linear BT.709 or BT.2020 RGB space, and clamps each -// component directly to [0, peak]. It adds no sectional gamut curve. -// 14. Experimental post-compression is independent of hull selection. Modes -// 1-8 branch from the common post-contrast LMS state before the physical -// per-cone shoulder, Graph/Fast60 hue authoring, or target-hull solve. -// Direct target-RGB per-channel and max-channel shoulders can be compared -// with adaptive-MB hard pullback, adaptive soft compression, RenoDX fixed- -// D65 soft compression, and source-MB-direction variants. Source BT.709 -// Residual retains the default coupled path and replaces only its final -// linear-BT.709 residual direction. PsychoV17 Gamut instead retains -// Test25's physical per-cone shoulder and authored hue, bypasses Test25's -// coupled target-hull solve, then applies PsychoV17's final adaptive- -// relative weighted-LMS target-primary compression. PsychoV17 Gamut + -// Neutwo Max retains that physical/hue trajectory for target-RGB direction, -// derives magnitude from the unbounded post-contrast signal after the same -// gamut map, then uses one anchor-normalized max-channel Neutwo peak map. -// PsychoV17 -// Gamut + NRG White instead retains the completed output's ACC-A scalar -// metric while moving an over-peak selected-target RGB result from its hue -// wall toward peak D65 white. None of these options changes the default -// coupled path. These remain comparison probes rather than candidate -// device-volume mappings: common-scale max-channel modes can terminate on -// a colored wall. -// 15. Sectional White Volume is an experimental coupled-hull alternative. It -// retains the physical per-cone Yf and Graph/Fast60 six-section direction, -// measures their selected-target RGB displacement from the same-Yf D65 -// axis, and applies one globally smooth L8 cube-occupancy response. Lower -// primary and upper peak planes participate in the same cross-sectional -// solve. There is no separate hue-wall handoff, white-spill pass, or final -// component clamp. The inherited physical per-cone endpoint still requires -// every positive hue trajectory to converge to peak D65 white. -// 16. Reference3 is an experimental target-hue-triangle volume map. The -// selected linear RGB cube is decomposed exactly into one triangle per hue: -// black, the max/min target-channel hue-rim point, and peak D65 white. -// Physical per-cone Yf and Graph/Fast60 direction supply the preferred -// point before legacy lower/upper hull passes. Smooth positive barycentric -// weights place an outside point inside its exact target triangle, while -// quadratic lower/upper pressure moves increasingly invalid points toward -// white. Active cube-edge changes come only from target geometry; there is -// no authored hue- or level-segment handoff. -// -// 17. Canonical Cylinder is an experimental star-volume map. It normalizes -// authored adaptive-MB radius by the exact selected-target six-plane radial -// support at each hue/Yf, making every target a unit q-cylinder. Outside -// occupancy is passed through a pivot/contrast/generalized-Neutwo pressure -// response and split between inward q contraction and upward motion toward -// peak D65 white. The target support is re-evaluated at the raised Yf before -// reconstructing the final radius. In-gamut q <= 1 points are exact identity. -// 18. Adaptive Contrast Fit is an experimental post-ideal lost-contrast fit. -// It first completes Test25's ordinary physical/MIDPOINT result, then fits -// that point to the exact selected-target six-plane adaptive-MB radial -// support at the same physical Yf. Lost adaptive-MB radius contributes only -// above the adapted Yf, while genuine lost achromatic Yf contributes -// separately. Their bounded pressure advances one later state on Test25's -// own per-cone/MIDPOINT trajectory, then reapplies the exact target fit. No -// straight target-RGB interpolation to white is used. -// -// Device-hull implementation: -// Peak and RGB-gamut constraints are one device-hull problem. For normalized -// BT.709 output, the complete target is the cube 0 <= R,G,B <= 1, not a -// per-channel move toward white followed by an unrelated gamut constraint. -// With both plane classes enabled, the gamut-active branch evaluates this full -// cube along the numerically solved adaptive-MB trajectory. White is one -// possible intermediate in-hull result, but peak D65 white is the required -// endpoint of every positive hue trajectory. A hue may travel along cube faces -// while clipping, but it must not terminate on a colored face. The restored -// source record and longer-term hull plan -// below distinguish this ray solve from a future sectional optimization over -// multiple candidate points. -// In wide-target mode, the result remains represented as linear BT.709 until -// the caller converts it for output. Negative BT.709 components are therefore -// valid when the represented color is inside the selected wider target. - -static const float PSYCHO25_EPSILON = 1e-6f; -static const float PSYCHO25_PI = 3.14159265358979323846f; -static const float PSYCHO25_TWO_PI = 6.2831853071795864769f; -static const float PSYCHO25_LARGE = 1e20f; -static const float PSYCHO25_MAX_FINITE_INPUT = 65504.f; -static const float PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE = 0.9f; -static const float PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION = 0.75f; -static const float PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON = 1e-5f; -static const float PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER = 256.f; -static const float PSYCHO25_SECTIONAL_VOLUME_POWER = 8.f; -static const float PSYCHO25_WHITE_LIFT_PRESSURE_EPSILON = 1e-7f; - -// Auto-compression reference. -// Simultaneous luminance dynamic range is stimulus- and method-dependent. -// Published values considered for the automatic compression reference: -// - Kunkel & Reinhard, APGV 2010, doi:10.1145/1836248.1836251: -// ~3.7 log10 units under their adapted test conditions. -// - Jiang & Fairchild, JIST 2021, -// doi:10.2352/J.ImagingSci.Technol.2021.65.5.050401: -// direct bright/dark simultaneous measurements on an Apple Pro Display -// XDR setup reported ~3.3 log10 units for the average observer and -// 3.47 log10 units for OBS1 at 1600 cd/m^2, 3.4 degree stimulus size. -// Their spatial-frequency fit reports DRmax values of 3.24 log10 at -// 452 cd/m^2 and 3.40 log10 at 1600 cd/m^2. The display apparatus used -// diffuse white = 50 cd/m^2 and peak luminance = 1600 cd/m^2. -// -// Default choice: -// Kunkel/Reinhard's 3.7 value is the conservative reference. A larger -// reference range increases auto h on low-headroom displays, reducing the -// symmetric curve's OFF/shadow-side bending. Jiang/Fairchild's average is a -// possible direct-display, glare-inclusive alternative. -// -// Model choice: -// For a neutral static curve, the adapted/background state is treated as the -// log midpoint of the selected total range. Half of the log range is above -// adaptation and half below. This is a neutral log-domain prior, not a claim -// that biological ON/OFF pathways are exactly symmetric. -// -// For the slope-normalized compression below, the deep OFF-side slope ratio is: -// S_shadow / contrast = 1 / (1 - pow(anchor_out / peak, h)) -// Auto compression solves: -// h = (reference_range_log10 / 2) / log10(peak / anchor_out) -// which is equivalent to choosing: -// pow(anchor_out / peak, h) = pow(10, -(reference_range_log10 / 2)) -// The implied OFF-side slope ratio is therefore derived from the selected -// reference range rather than from an independent decimal tolerance. -static const float PSYCHO25_REFERENCE_SIMULTANEOUS_RANGE_LOG10 = 3.7f; -static const float PSYCHO25_REFERENCE_CENTERED_RANGE_SIDE_COUNT = 2.f; -// Target-relative neutral Yf endpoint for target-plane hue guidance. Scale 1 -// exactly matches the regular physical per-channel endpoint. -static const float PSYCHO25_MIN_GUIDANCE_PEAK_SCALE = 1.f; -static const float PSYCHO25_DEFAULT_GUIDANCE_PEAK_SCALE = 1.f; -static const float PSYCHO25_MIN_AUTO_COMPRESSION = 1.f; -static const float PSYCHO25_MIN_MANUAL_COMPRESSION = 1e-6f; -static const float PSYCHO25_AUTO_COMPRESSION_SENTINEL = 0.f; -static const float PSYCHO25_UPPER_PLANE_SHOULDER_POWER_MATCH_COMPRESSION = 0.f; - -// RenoDX v4 grading masks are applied to scalar Yf rather than independently -// to L, M, and S. This keeps the adapted anchor fixed and prevents the -// highlight/shadow controls from rotating adaptive-MB hue. -static const float PSYCHO25_HIGHLIGHT_GRADE_REFERENCE_WHITE = 1.f; -static const float PSYCHO25_SHADOW_GRADE_RANGE_STOPS = 4.f; - -// Numerical Graph searches each cone-axis-bounded interval and inverts the -// transformed hue field. Fast60 bypasses these constants and searches. -static const uint PSYCHO25_HUE_PEAK_SCAN_INTERVALS = 6u; -static const uint PSYCHO25_HUE_PEAK_REFINE_ITERATIONS = 12u; -static const uint PSYCHO25_HUE_INVERSE_BRACKET_INTERVALS = 16u; -static const uint PSYCHO25_HUE_INVERSE_ITERATIONS = 18u; -static const float PSYCHO25_HUE_REVERSAL_AXIS_SLOPE_LIMIT = -6.f; -static const float PSYCHO25_HUE_ORDER_DERIVATIVE_PROBE_DIVISOR = 64.f; -static const float PSYCHO25_HUE_ORDER_SAFETY = 0.9f; - -static const float PSYCHO25_HUE_AMPLITUDE = 0.5f; -static const int PSYCHO25_HUE_METHOD_GRAPH = 0; -static const int PSYCHO25_HUE_METHOD_FAST_60 = 1; -static const int PSYCHO25_HULL_METHOD_REFERENCE_SCALE = 0; -static const int PSYCHO25_HULL_METHOD_REDUCED_MAX_WHITE = 1; -static const int PSYCHO25_HULL_METHOD_LINEAR_MB_PULLBACK = 2; -static const int PSYCHO25_HULL_METHOD_TARGET_RGB_CLIP = 3; -static const int PSYCHO25_HULL_METHOD_SECTIONAL_WHITE_VOLUME = 4; -static const int PSYCHO25_HULL_METHOD_REFERENCE2 = 5; -static const int PSYCHO25_HULL_METHOD_REFERENCE3 = 6; -static const int PSYCHO25_HULL_METHOD_CANONICAL_CYLINDER = 7; -static const int PSYCHO25_HULL_METHOD_CANONICAL_YF_CONE = 8; -// Canonical-cylinder experimental defaults. The target RGB cube is reduced to -// q = rho / rho_max(theta, Yf); outside pressure is then redirected both -// inward in q and upward toward peak D65 white before converting back through -// the exact target radial support at the raised Yf. -static const float PSYCHO25_CANONICAL_CYLINDER_DEFAULT_PIVOT = 0.45f; -static const float PSYCHO25_CANONICAL_CYLINDER_DEFAULT_CONTRAST = 1.4f; -static const float PSYCHO25_CANONICAL_CYLINDER_DEFAULT_H = 2.f; -static const float PSYCHO25_CANONICAL_CYLINDER_DEFAULT_TRADE = 0.5f; -// Yf-cone variant: exponent controlling how quickly gamut pressure is allowed -// to become whiteward/achromatic motion. k=2 gives 1% whiteward pressure at -// 10% of target peak, 25% at 50%, and 81% at 90%. -static const float PSYCHO25_CANONICAL_YF_CONE_DEFAULT_BIAS_POWER = 2.f; -static const int PSYCHO25_POST_COMPRESSION_NONE = 0; -static const int PSYCHO25_POST_COMPRESSION_DIRECT = 1; -static const int PSYCHO25_POST_COMPRESSION_PER_CHANNEL = 2; -static const int PSYCHO25_POST_COMPRESSION_MAX_CHANNEL = 3; -static const int PSYCHO25_POST_COMPRESSION_ADAPTIVE_MB_HARD_MAX = 4; -static const int PSYCHO25_POST_COMPRESSION_ADAPTIVE_MB_SOFT_MAX = 5; -static const int PSYCHO25_POST_COMPRESSION_FIXED_D65_SOFT_MAX = 6; -static const int PSYCHO25_POST_COMPRESSION_SOURCE_MB_PER_CHANNEL = 7; -static const int PSYCHO25_POST_COMPRESSION_SOURCE_MB_SOFT_MAX = 8; -static const int PSYCHO25_POST_COMPRESSION_SOURCE_BT709_RESIDUAL = 9; -static const int PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT = 10; -static const int PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NEUTWO_MAX = 11; -static const int PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NRG_WHITE = 12; -static const int PSYCHO25_POST_COMPRESSION_ADAPTIVE_CONTRAST_FIT = 13; -static const int PSYCHO25_UPPER_HULL_PIVOT_BLACK = 0; -static const int PSYCHO25_UPPER_HULL_PIVOT_ADAPTED_OUTPUT = 1; -static const float PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY = 0.8f; -static const float PSYCHO25_REDUCED_MAX_WHITE_SOURCE_DIRECTION_OCCUPANCY = 1.f; -static const float PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION = 0.25f; -static const float PSYCHO25_SOURCE_DIRECTION_BLEND_POWER = 2.f; -static const int PSYCHO25_GAMUT_ENFORCEMENT_NONE = 0; -static const int PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES = 1; -static const int PSYCHO25_GAMUT_ENFORCEMENT_PEAK = 2; -static const int PSYCHO25_GAMUT_ENFORCEMENT_FULL = - PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES - | PSYCHO25_GAMUT_ENFORCEMENT_PEAK; -static const int PSYCHO25_GAMUT_ENFORCEMENT_LEGACY = -1; -static const int PSYCHO25_INPUT_PRESTEP_NONE = 0; -static const int PSYCHO25_INPUT_PRESTEP_POSITIVE_LMS = 1; -static const int PSYCHO25_INPUT_PRESTEP_CIE1702 = 2; -static const int PSYCHO25_INPUT_PRESTEP_CIE1702_ABSOLUTE_YF = 3; -static const int PSYCHO25_OBSERVER_GAMUT_NONE = 0; -static const int PSYCHO25_OBSERVER_GAMUT_CIE1702 = 1; - -struct Psycho25HueSection { - float start; - float end; - float midpoint; - float source_unwrapped; - uint index; -}; - -struct Psycho25HueGeometry { - float peak_angle; - float peak_shift; - float axis_slope; - float maximum_ordered_amplitude; - uint active; -}; - -struct Psycho25ConeResponseState { - float3 encoded_response; - float3 compression_exponent; - float3 input_response_exponent; - float3 encoded_peak_offset; -}; - -struct Psycho25ConeResponseParameters { - float3 anchor_out; - float3 compression_exponent; - float3 input_response_exponent; - float3 encoded_peak_offset; - float encoded_response_power; - float inverse_compression_power; -}; - -struct Psycho25HueEvaluationContext { - Psycho25ConeResponseParameters guidance_cone_response; - float3 current_adaptive_state_lms; - float3 anchor_in; - float3 anchor_out; - float3 guidance_lms_peak; - float2 adapted_neutral_mb; - float source_radius; - float source_target_yf; - float contrast_power; - int observer_gamut_mode; -}; - -struct Psycho25AdaptiveMBTrajectory { - float3 authored_mb; - uint hue_applied; -}; - -float psycho25_Cross2(float2 a, float2 b) { - return a.x * b.y - a.y * b.x; -} - -float psycho25_PositiveHueAngle(float angle) { - angle -= PSYCHO25_TWO_PI * floor(angle / PSYCHO25_TWO_PI); - return angle < 0.f ? angle + PSYCHO25_TWO_PI : angle; -} - -float psycho25_SignedYfFromLMS(float3 lms) { - float3 weighted_lms = - renodx::color::macleod_boynton::WeighLMS(lms); - return weighted_lms.x + weighted_lms.y; -} - -float psycho25_YfFromLMS(float3 lms) { - return max( - psycho25_SignedYfFromLMS(lms), - PSYCHO25_EPSILON); -} - -// Map a signed LMS input onto its D65-relative CIE 170-2 hue ray while -// retaining the absolute-L/M Yf magnitude already used by Test25 grading. -// This is not radiant energy: Yf is the weighted L+M coordinate and excludes -// S. Signed L+M supplies the source ray when defined; its absolute-LMS ray is -// the fallback when the signed denominator is degenerate. -float3 psycho25_AlignInputToCIE1702Hue(float3 lms_input) { - float3 lms_weighted = - renodx::color::macleod_boynton::WeighLMS(lms_input); - float3 lms_weighted_absolute = abs(lms_weighted); - float absolute_yf = - lms_weighted_absolute.x + lms_weighted_absolute.y; - if (!(absolute_yf > PSYCHO25_EPSILON)) { - return 0.f.xxx; - } - - float signed_yf = lms_weighted.x + lms_weighted.y; - float2 source_ls = abs(signed_yf) > PSYCHO25_EPSILON - ? float2(lms_weighted.x, lms_weighted.z) / signed_yf - : float2(lms_weighted_absolute.x, lms_weighted_absolute.z) - / absolute_yf; - float2 white_ls = renodx::color::gamut::CIE1702WhiteChromaticity(); - float2 direction = source_ls - white_ls; - float t_final = 1.f; - if (dot(direction, direction) - > renodx::color::gamut::MB_NEAR_WHITE_EPSILON) { - t_final = min( - 1.f, - renodx::color::gamut::RayExitTCIE1702PreciseD(direction)); - } - - return renodx::color::macleod_boynton::UnweighLMS( - renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton( - white_ls + t_final * direction, - absolute_yf)); -} - -float3 psycho25_ApplyInputPreStep(float3 lms_input, int input_pre_step) { - if (input_pre_step == PSYCHO25_INPUT_PRESTEP_POSITIVE_LMS) { - return max(lms_input, 0.f.xxx); - } - if (input_pre_step == PSYCHO25_INPUT_PRESTEP_CIE1702) { - return renodx::color::gamut::GamutCompressLMSPrecise(lms_input); - } - if (input_pre_step == PSYCHO25_INPUT_PRESTEP_CIE1702_ABSOLUTE_YF) { - return psycho25_AlignInputToCIE1702Hue(lms_input); - } - return lms_input; -} - -float3 psycho25_ApplyObserverGamutCompression( - float3 contrast_lms, - int observer_gamut_mode) { - if (observer_gamut_mode == PSYCHO25_OBSERVER_GAMUT_CIE1702) { - return renodx::color::gamut::GamutCompressLMSPrecise(contrast_lms); - } - return contrast_lms; -} - -// Apply the optional fixed-observer constraint to actual LMS immediately -// after independent per-cone contrast. Graph candidates use this same stage, -// so an observer-invalid candidate cannot define the authored hue field. -float3 psycho25_ApplyContrastResponse( - float3 lms_input, - float3 anchor_in, - float3 anchor_out, - float contrast_power, - int observer_gamut_mode) { - float3 contrast_lms = anchor_out - * renodx::math::SignPow( - lms_input / anchor_in, - contrast_power); - return psycho25_ApplyObserverGamutCompression( - contrast_lms, - observer_gamut_mode); -} - -float psycho25_GradeQuinticUnitRamp(float t) { - t = saturate(t); - return t * t * t * (t * (t * 6.f - 15.f) + 10.f); -} - -Psycho25ConeResponseParameters psycho25_PrepareConeResponseParameters( - float3 anchor_out, - float3 lms_peak, - float contrast_power, - float compression_power, - float encoded_response_power) { - Psycho25ConeResponseParameters parameters; - parameters.anchor_out = max(anchor_out, PSYCHO25_EPSILON.xxx); - float3 anchor_over_peak = parameters.anchor_out / lms_peak; - float3 anchor_peak_power = pow( - anchor_over_peak, - compression_power); - float3 compression_slope_norm = 1.f - anchor_peak_power; - parameters.compression_exponent = compression_power - / compression_slope_norm; - parameters.encoded_response_power = max( - encoded_response_power, - PSYCHO25_EPSILON); - parameters.input_response_exponent = max( - contrast_power, - PSYCHO25_EPSILON) - * parameters.compression_exponent - * parameters.encoded_response_power; - // (peak / anchor)^h - 1 == 1 / (anchor / peak)^h - 1. - parameters.encoded_peak_offset = rcp(anchor_peak_power) - 1.f; - parameters.inverse_compression_power = rcp(compression_power); - return parameters; -} - -// Scalar RenoDX v4 highlight grade. -// highlights > 1 increases highlights; highlights < 1 reduces them. -// The adapted anchor is an exact fixed point. -float psycho25_HighlightsScalarV4( - float x, - float highlights, - float adapted_anchor_yf) { - if (highlights == 1.f) return x; - - float t = 0.f; - if (x > adapted_anchor_yf) { - float reference_range_log2 = log2( - PSYCHO25_HIGHLIGHT_GRADE_REFERENCE_WHITE - / max(adapted_anchor_yf, PSYCHO25_EPSILON)); - t = saturate( - log2(x / max(adapted_anchor_yf, PSYCHO25_EPSILON)) - / max(reference_range_log2, PSYCHO25_EPSILON)); - } - t = psycho25_GradeQuinticUnitRamp(t); - - float ratio = max( - x / max(adapted_anchor_yf, PSYCHO25_EPSILON), - PSYCHO25_EPSILON); - if (highlights > 1.f) { - return lerp( - x, - adapted_anchor_yf * pow(ratio, highlights), - t); - } - - float b = adapted_anchor_yf * pow(ratio, 2.f - highlights); - return renodx::math::DivideSafe(x * x, lerp(x, b, t), x); -} - -// Scalar RenoDX v4 shadow grade. -// shadows > 1 brightens shadows; shadows < 1 darkens them. -// The adapted anchor is an exact fixed point; the mask reaches full strength -// at the deep-shadow reference. -float psycho25_ShadowsScalarV4( - float x, - float shadows, - float adapted_anchor_yf) { - if (shadows == 1.f) return x; - - float ratio = max( - renodx::math::DivideSafe(x, adapted_anchor_yf, 0.f), - 0.f); - float base_term = x * adapted_anchor_yf; - float base_scale = renodx::math::DivideSafe(base_term, ratio, 0.f); - float shadow_floor = - adapted_anchor_yf * exp2(-PSYCHO25_SHADOW_GRADE_RANGE_STOPS); - - float t = 1.f; - if (x > shadow_floor) { - t = saturate( - log2(x / max(adapted_anchor_yf, PSYCHO25_EPSILON)) - / log2( - shadow_floor - / max(adapted_anchor_yf, PSYCHO25_EPSILON))); - } - t = psycho25_GradeQuinticUnitRamp(t); - - if (shadows > 1.f) { - float raised = x * (1.f + renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO25_EPSILON), shadows), 0.f)); - float reference = x * (1.f + base_scale); - return x + (raised - reference) * t; - } - - float lowered = x * (1.f - renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO25_EPSILON), 2.f - shadows), 0.f)); - float reference = x * (1.f - base_scale); - return x + (lowered - reference) * t; -} - -float psycho25_AutoCompressionFromCenteredReferenceRange( - float anchor_out_yf, - float peak_yf) { - float peak_over_anchor = peak_yf / anchor_out_yf; - - float reference_one_side_range_log10 = - PSYCHO25_REFERENCE_SIMULTANEOUS_RANGE_LOG10 - / PSYCHO25_REFERENCE_CENTERED_RANGE_SIDE_COUNT; - float actual_above_adaptation_range_log10 = log10(peak_over_anchor); - return max( - reference_one_side_range_log10 - / actual_above_adaptation_range_log10, - PSYCHO25_MIN_AUTO_COMPRESSION); -} - -float psycho25_ResolveGuidancePeakYf( - float target_peak_yf, - float guidance_peak_scale) { - return target_peak_yf - * max(guidance_peak_scale, PSYCHO25_MIN_GUIDANCE_PEAK_SCALE); -} - -float3 psycho25_ToAdaptiveRelativeWeightedLMS( - float3 lms_input, - float3 current_adaptive_state_lms) { - return renodx::math::DivideSafe( - renodx::color::macleod_boynton::WeighLMS(lms_input), - current_adaptive_state_lms, - 0.f.xxx); -} - -float3 psycho25_FromAdaptiveRelativeWeightedLMS( - float3 lms_weighted_relative, - float3 current_adaptive_state_lms) { - return lms_weighted_relative - * max(current_adaptive_state_lms, PSYCHO25_EPSILON.xxx); -} - -float3 psycho25_LMSFromAdaptiveMB( - float3 mb, - float3 current_adaptive_state_lms) { - float3 relative_weighted = - renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton(mb); - return renodx::color::macleod_boynton::UnweighLMS( - psycho25_FromAdaptiveRelativeWeightedLMS( - relative_weighted, - current_adaptive_state_lms)); -} - -float3 psycho25_ApplyAdaptiveMBPurity( - float3 lms_input, - float3 adaptive_neutral_lms, - float purity_delta) { - if (abs(purity_delta - 1.f) <= 1e-5f) return lms_input; - - float3 relative_weighted = - psycho25_ToAdaptiveRelativeWeightedLMS( - lms_input, - adaptive_neutral_lms); - float3 mb = - renodx::color::macleod_boynton::from::WeightedLMS( - relative_weighted); - float3 mb_neutral = - renodx::color::macleod_boynton::from::LMS(1.f.xxx); - float2 mb_scaled_xy = lerp(mb_neutral.xy, mb.xy, purity_delta); - float3 relative_weighted_out = - renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton( - float3(mb_scaled_xy, mb.z)); - return renodx::color::macleod_boynton::UnweighLMS( - psycho25_FromAdaptiveRelativeWeightedLMS( - relative_weighted_out, - adaptive_neutral_lms)); -} - -float2 psycho25_AdaptiveMBDirection( - float3 lms_input, - float3 current_adaptive_state_lms, - float2 adapted_neutral_mb) { - float3 relative_weighted = - psycho25_ToAdaptiveRelativeWeightedLMS( - lms_input, - current_adaptive_state_lms); - float3 mb = - renodx::color::macleod_boynton::from::WeightedLMS( - relative_weighted); - float2 offset = mb.xy - adapted_neutral_mb; - float radius2 = dot(offset, offset); - if (radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) return 0.f.xx; - return offset * rsqrt(radius2); -} - -float2 psycho25_IsolatedConeDisplacementAxis( - float3 current_adaptive_state_lms, - float2 adapted_neutral_mb, - uint cone_index) { - float3 displaced_lms = current_adaptive_state_lms; - if (cone_index == 0u) { - displaced_lms.x *= 2.f; - } else if (cone_index == 1u) { - displaced_lms.y *= 2.f; - } else { - displaced_lms.z *= 2.f; - } - return psycho25_AdaptiveMBDirection( - displaced_lms, - current_adaptive_state_lms, - adapted_neutral_mb); -} - -Psycho25HueSection psycho25_HuePinIntervalForAngle( - float source_hue_angle, - float2 axis_l, - float2 axis_m, - float2 axis_s) { - // The raw per-cone field is exactly zero on each isolated-cone axis and its - // antipode. These rays delimit inversion intervals so every cone-axis pin is - // retained without a separate dominance-order topology. - float pin_l = psycho25_PositiveHueAngle(atan2(axis_l.y, axis_l.x)); - float pin_m = psycho25_PositiveHueAngle(atan2(axis_m.y, axis_m.x)); - float pin_s = psycho25_PositiveHueAngle(atan2(axis_s.y, axis_s.x)); - float pin_minus_l = psycho25_PositiveHueAngle(pin_l + PSYCHO25_PI); - float pin_minus_m = psycho25_PositiveHueAngle(pin_m + PSYCHO25_PI); - float pin_minus_s = psycho25_PositiveHueAngle(pin_s + PSYCHO25_PI); - - float angle = psycho25_PositiveHueAngle(source_hue_angle); - Psycho25HueSection interval; - if (angle >= pin_l || angle < pin_minus_m) { - interval.start = pin_l; - interval.end = pin_minus_m + PSYCHO25_TWO_PI; - interval.source_unwrapped = angle < pin_minus_m - ? angle + PSYCHO25_TWO_PI - : angle; - interval.index = 0u; - } else if (angle < pin_s) { - interval.start = pin_minus_m; - interval.end = pin_s; - interval.source_unwrapped = angle; - interval.index = 1u; - } else if (angle < pin_minus_l) { - interval.start = pin_s; - interval.end = pin_minus_l; - interval.source_unwrapped = angle; - interval.index = 2u; - } else if (angle < pin_m) { - interval.start = pin_minus_l; - interval.end = pin_m; - interval.source_unwrapped = angle; - interval.index = 3u; - } else if (angle < pin_minus_s) { - interval.start = pin_m; - interval.end = pin_minus_s; - interval.source_unwrapped = angle; - interval.index = 4u; - } else { - interval.start = pin_minus_s; - interval.end = pin_l; - interval.source_unwrapped = angle; - interval.index = 5u; - } - interval.midpoint = 0.5f * (interval.start + interval.end); - return interval; -} - -Psycho25HueEvaluationContext psycho25_PrepareHueEvaluationContext( - Psycho25ConeResponseParameters guidance_cone_response, - float3 current_adaptive_state_lms, - float3 anchor_in, - float3 anchor_out, - float3 guidance_lms_peak, - float2 adapted_neutral_mb, - float source_radius, - float source_target_yf, - float contrast_power, - int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { - Psycho25HueEvaluationContext context; - context.guidance_cone_response = guidance_cone_response; - context.current_adaptive_state_lms = current_adaptive_state_lms; - context.anchor_in = anchor_in; - context.anchor_out = anchor_out; - context.guidance_lms_peak = guidance_lms_peak; - context.adapted_neutral_mb = adapted_neutral_mb; - context.source_radius = source_radius; - context.source_target_yf = source_target_yf; - context.contrast_power = contrast_power; - context.observer_gamut_mode = observer_gamut_mode; - return context; -} - -float3x3 psycho25_WeightedLMSToRGBMatrix( - int gamut_mode) { - return gamut_mode == 0 - ? renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT709_MAT - : renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT2020_MAT; -} - -float3 psycho25_TargetRGBFromLMS( - float3 lms, - int gamut_mode) { - return mul( - psycho25_WeightedLMSToRGBMatrix(gamut_mode), - renodx::color::macleod_boynton::WeighLMS(lms)); -} - - float3 psycho25_LMSFromTargetRGB( - float3 target_rgb, - int gamut_mode) { - return gamut_mode == 0 - ? renodx::color::lms::from::BT709(target_rgb) - : renodx::color::lms::from::BT2020(target_rgb); - } - -float psycho25_TargetLowerPlaneBoundaryFraction( - float3 candidate_target_rgb, - float3 neutral_target_rgb) { - float boundary_fraction = PSYCHO25_LARGE; - if (candidate_target_rgb.x < neutral_target_rgb.x) { - boundary_fraction = min( - boundary_fraction, - neutral_target_rgb.x - / (neutral_target_rgb.x - candidate_target_rgb.x)); - } - if (candidate_target_rgb.y < neutral_target_rgb.y) { - boundary_fraction = min( - boundary_fraction, - neutral_target_rgb.y - / (neutral_target_rgb.y - candidate_target_rgb.y)); - } - if (candidate_target_rgb.z < neutral_target_rgb.z) { - boundary_fraction = min( - boundary_fraction, - neutral_target_rgb.z - / (neutral_target_rgb.z - candidate_target_rgb.z)); - } - return boundary_fraction; -} - - float3 psycho25_PullBackAdaptiveMBToTargetLowerPlanes( - float3 candidate_mb, - float2 adapted_neutral_mb, - float3 current_adaptive_state_lms, - int target_gamut_mode) { - float3 neutral_lms = psycho25_LMSFromAdaptiveMB( - float3(adapted_neutral_mb, candidate_mb.z), - current_adaptive_state_lms); - float3 candidate_lms = psycho25_LMSFromAdaptiveMB( - candidate_mb, - current_adaptive_state_lms); - float boundary_fraction = psycho25_TargetLowerPlaneBoundaryFraction( - psycho25_TargetRGBFromLMS(candidate_lms, target_gamut_mode), - psycho25_TargetRGBFromLMS(neutral_lms, target_gamut_mode)); - candidate_mb.xy = lerp( - adapted_neutral_mb, - candidate_mb.xy, - saturate(boundary_fraction)); - return candidate_mb; - } - -float psycho25_CompressTargetLowerPlaneRadius( - float boundary_fraction) { - float knee = PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE - * boundary_fraction; - float headroom = boundary_fraction - knee; - float excess = max(1.f - knee, 0.f); - return 1.f - excess - + renodx::math::DivideSafe( - headroom * excess, - headroom + excess, - 0.f); -} - -float psycho25_SmoothPositive(float value) { - float smooth_length = sqrt( - value * value - + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON - * PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); - float normalized_value = value / smooth_length; - return 0.5f - * value - * normalized_value - * (1.f + normalized_value); -} - -float psycho25_IntersectTargetPlaneSupports(float a, float b) { - float normalization = max(a, b); - float normalized_a = a / normalization; - float normalized_b = b / normalization; - float denominator = normalization * pow(pow(normalized_a, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER) + pow(normalized_b, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER), rcp(PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER)); - return a * b / denominator; -} - -float psycho25_IntersectTargetPlaneSupports(float3 support) { - return psycho25_IntersectTargetPlaneSupports( - support.x, - psycho25_IntersectTargetPlaneSupports( - support.y, - support.z)); -} - -float3 psycho25_LiftTargetRGBTowardWhite( - float3 candidate_lms, - float white_level, - int target_gamut_mode) { - float3 candidate_target_rgb = psycho25_TargetRGBFromLMS( - candidate_lms, - target_gamut_mode); - float3 white_target_rgb = white_level.xxx; - - // For q = lerp(candidate, white, t), each negative channel requires - // t >= -candidate / (white - candidate). SmoothPositiveMajorant is strictly - // no smaller than max(required, 0), and the bounded transform retains that - // conservative property. The union is smooth across active target planes - // and remains at least as large as every channel's required lift. - float3 required_lift = -candidate_target_rgb / max( - white_target_rgb - candidate_target_rgb, - PSYCHO25_EPSILON.xxx); - float3 smooth_positive_majorant = 0.5f * ( - required_lift - + sqrt( - required_lift * required_lift - + PSYCHO25_WHITE_LIFT_PRESSURE_EPSILON - * PSYCHO25_WHITE_LIFT_PRESSURE_EPSILON)); - float3 channel_lift = smooth_positive_majorant / ( - 1.f + smooth_positive_majorant - required_lift); - float minimum_white_lift = 1.f - - (1.f - channel_lift.x) - * (1.f - channel_lift.y) - * (1.f - channel_lift.z); - - // The minimum lift lands an outside point on its limiting lower plane. - // For fixed-ray occupancy s and t = 1 - 1/s, multiplying the remaining - // displacement by 1 - t^2 maps the result to occupancy 1 - t^2 inside that - // plane. It leaves the boundary with zero first-order inward motion, then - // converges to white as pressure grows instead of flattening the outside - // trajectory onto a target wall. The scalar residual keeps the selected- - // target D65-relative direction intact; the Reference2 wrapper below - // restores the exact Graph-authored adaptive-MB hue after the move. - float white_residual = (1.f - minimum_white_lift) - * (1.f - minimum_white_lift * minimum_white_lift); - float3 output_target_rgb = white_target_rgb - + white_residual * (candidate_target_rgb - white_target_rgb); - return psycho25_LMSFromTargetRGB( - output_target_rgb, - target_gamut_mode); -} - -float3 psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( - float3 candidate_lms, - float3 current_adaptive_state_lms, - float white_level, - int target_gamut_mode) { - float3 lifted_lms = psycho25_LiftTargetRGBTowardWhite( - candidate_lms, - white_level, - target_gamut_mode); - float2 adapted_neutral_mb = - renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - float3 lifted_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - psycho25_ToAdaptiveRelativeWeightedLMS( - lifted_lms, - current_adaptive_state_lms)); - float2 candidate_direction = psycho25_AdaptiveMBDirection( - candidate_lms, - current_adaptive_state_lms, - adapted_neutral_mb); - float2 output_mb_xy = adapted_neutral_mb - + candidate_direction - * length(lifted_mb.xy - adapted_neutral_mb); - float output_yf = psycho25_YfFromLMS(lifted_lms); - float output_mb_scale = renodx::math::DivideSafe( - output_yf, - output_mb_xy.x * current_adaptive_state_lms.x - + (1.f - output_mb_xy.x) * current_adaptive_state_lms.y, - 0.f); - return psycho25_LMSFromAdaptiveMB( - float3(output_mb_xy, output_mb_scale), - current_adaptive_state_lms); -} - -float3 psycho25_CompressTargetHueTriangleVolume( - float3 preferred_lms, - float peak_value, - int target_gamut_mode) { - float safe_peak = max(peak_value, PSYCHO25_EPSILON); - float3 preferred_target_rgb = psycho25_TargetRGBFromLMS( - preferred_lms, - target_gamut_mode); - float minimum_channel = min( - preferred_target_rgb.x, - min(preferred_target_rgb.y, preferred_target_rgb.z)); - float maximum_channel = max( - preferred_target_rgb.x, - max(preferred_target_rgb.y, preferred_target_rgb.z)); - float channel_range = maximum_channel - minimum_channel; - - // For target RGB x, C = peak * (x - min(x)) / (max(x) - min(x)) - // is the exact hue-rim point with min(C)=0 and max(C)=peak. The raw - // barycentric weights reproduce every in-cube point exactly: - // x = black_weight * 0 + hue_weight * C + white_weight * peak.xxx. - float3 hue_rim_target_rgb = safe_peak - * (preferred_target_rgb - minimum_channel.xxx) - / max(channel_range, PSYCHO25_EPSILON); - float3 raw_weights = float3( - 1.f - maximum_channel / safe_peak, - channel_range / safe_peak, - minimum_channel / safe_peak); - - // Smoothly project invalid barycentric coordinates into the target - // triangle. This is effectively identity for positive in-volume weights - // and evaluates every simplex face together without authored section tests. - float3 positive_weights = float3( - psycho25_SmoothPositive(raw_weights.x), - psycho25_SmoothPositive(raw_weights.y), - psycho25_SmoothPositive(raw_weights.z)); - float3 contained_weights = positive_weights - / max( - positive_weights.x + positive_weights.y + positive_weights.z, - PSYCHO25_EPSILON); - - // Negative black weight means an upper-plane violation; negative white - // weight means a lower-plane violation. The squared pressure has zero slope - // at first contact, then approaches one under extreme pressure. Moving the - // contained point toward the triangle's white vertex makes white—not a dark - // target wall—the terminal fallback for either class of violation. - float upper_pressure = psycho25_SmoothPositive(-raw_weights.x); - float lower_pressure = psycho25_SmoothPositive(-raw_weights.z); - float upper_white_weight = upper_pressure * upper_pressure - / (1.f + upper_pressure * upper_pressure); - float lower_white_weight = lower_pressure * lower_pressure - / (1.f + lower_pressure * lower_pressure); - float pressure_white_weight = 1.f - - (1.f - upper_white_weight) * (1.f - lower_white_weight); - contained_weights = lerp( - contained_weights, - float3(0.f, 0.f, 1.f), - pressure_white_weight); - - float3 output_target_rgb = contained_weights.y * hue_rim_target_rgb - + contained_weights.z * safe_peak.xxx; - return psycho25_LMSFromTargetRGB( - output_target_rgb, - target_gamut_mode); -} - -float psycho25_TargetLowerPlaneRadiusForDirection( - float2 direction, - float2 adapted_neutral_mb, - float3 current_adaptive_state_lms, - int target_gamut_mode) { - float3 neutral_lms = psycho25_LMSFromAdaptiveMB( - float3(adapted_neutral_mb, 1.f), - current_adaptive_state_lms); - float3 unit_radius_lms = psycho25_LMSFromAdaptiveMB( - float3(adapted_neutral_mb + direction, 1.f), - current_adaptive_state_lms); - float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( - neutral_lms, - target_gamut_mode); - float3 direction_target_rgb = psycho25_TargetRGBFromLMS( - unit_radius_lms - neutral_lms, - target_gamut_mode); - float3 lower_support = neutral_target_rgb / (float3(psycho25_SmoothPositive(-direction_target_rgb.x), psycho25_SmoothPositive(-direction_target_rgb.y), psycho25_SmoothPositive(-direction_target_rgb.z)) + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); - return psycho25_IntersectTargetPlaneSupports(lower_support); -} - - float3 psycho25_CompressSectionalWhiteVolume( - float3 preferred_lms, - float3 current_adaptive_state_lms, - float peak_value, - int target_gamut_mode, - int gamut_enforcement) { - const bool enforce_gamut_primaries = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; - const bool enforce_gamut_peak = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; - if (!enforce_gamut_primaries && !enforce_gamut_peak) { - return preferred_lms; - } - - // Hold the preferred physical result's Yf fixed and measure its selected- - // target RGB displacement from the D65 axis at that same Yf. Scaling this - // displacement therefore changes only the target-RGB color direction and - // magnitude, not the already-authored achromatic response. - float preferred_yf = psycho25_YfFromLMS(preferred_lms); - float3 neutral_lms = current_adaptive_state_lms - * renodx::math::DivideSafe( - preferred_yf, - psycho25_YfFromLMS(current_adaptive_state_lms), - 0.f); - float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( - neutral_lms, - target_gamut_mode); - float3 preferred_target_rgb = psycho25_TargetRGBFromLMS( - preferred_lms, - target_gamut_mode); - float3 target_displacement = preferred_target_rgb - neutral_target_rgb; - - // Each normalized occupancy is zero when its plane is not approached and - // one where the uncompressed displacement reaches that plane. Their L8 norm - // is a smooth conservative union of all enabled cube faces: it is never - // smaller than any individual occupancy, including at face/edge ties. - float3 lower_occupancy = 0.f.xxx; - float3 upper_occupancy = 0.f.xxx; - if (enforce_gamut_primaries) { - lower_occupancy = max(-target_displacement, 0.f.xxx) - / max(neutral_target_rgb, PSYCHO25_EPSILON.xxx); - } - if (enforce_gamut_peak) { - upper_occupancy = max(target_displacement, 0.f.xxx) - / max( - peak_value.xxx - neutral_target_rgb, - PSYCHO25_EPSILON.xxx); - } - float3 lower_occupancy_power = pow( - lower_occupancy, - PSYCHO25_SECTIONAL_VOLUME_POWER.xxx); - float3 upper_occupancy_power = pow( - upper_occupancy, - PSYCHO25_SECTIONAL_VOLUME_POWER.xxx); - float occupancy_power_sum = - lower_occupancy_power.x - + lower_occupancy_power.y - + lower_occupancy_power.z - + upper_occupancy_power.x - + upper_occupancy_power.y - + upper_occupancy_power.z; - - // One global saturation response replaces a black-to-wall/white handoff. - // It is nearly identity inside the cube, maps a single-face occupancy of one - // to pow(2, -1/8), and asymptotically approaches every active boundary from - // inside without a final component clamp. - float displacement_scale = pow( - 1.f + occupancy_power_sum, - -rcp(PSYCHO25_SECTIONAL_VOLUME_POWER)); - return psycho25_LMSFromTargetRGB( - neutral_target_rgb + target_displacement * displacement_scale, - target_gamut_mode); - } - -float3 psycho25_LMSFromHueDirectionAndYf( - float2 direction, - float source_radius, - float source_target_yf, - float3 current_adaptive_state_lms, - float2 adapted_neutral_mb) { - float3 candidate = psycho25_LMSFromAdaptiveMB( - float3(adapted_neutral_mb + direction * source_radius, 1.f), - current_adaptive_state_lms); - float candidate_yf = psycho25_YfFromLMS(candidate); - return candidate * renodx::math::DivideSafe(source_target_yf, candidate_yf, 1.f); -} - - -// Exact selected-target radial support at one adaptive-MB hue and physical Yf. -// Test25's adaptation-relative MB reconstruction makes target RGB a linear- -// fractional function of radius rather than a simple affine ray. Each enabled -// RGB cube face still has one closed-form scalar intersection, so no per-pixel -// search or LUT is required. -float psycho25_TargetRadialSupportAtYf( - float2 direction, - float target_yf, - float3 current_adaptive_state_lms, - float2 adapted_neutral_mb, - float peak_value, - int target_gamut_mode, - int gamut_enforcement) { - const bool enforce_gamut_primaries = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; - const bool enforce_gamut_peak = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; - if (!enforce_gamut_primaries && !enforce_gamut_peak) { - return PSYCHO25_LARGE; - } - - // Along an adaptive-MB radial line - // l(r) = l0 + r*dl, s(r) = s0 + r*ds, - // the adaptation-relative weighted-LMS numerator is affine in r. Restoring - // absolute LMS and then fixing physical Yf divides by the affine L+M term, - // so each target RGB channel is a linear-fractional function: - // - // RGB_i(r) = target_yf * (N0_i + r*N1_i) / (D0 + r*D1). - // - // Intersecting RGB_i(r) with a lower plane 0 or upper plane peak therefore - // has one closed-form positive root. This is exact for Test25's adaptive-MB - // construction and avoids a per-pixel binary search. - float3 relative_weighted_zero = float3( - adapted_neutral_mb.x, - 1.f - adapted_neutral_mb.x, - adapted_neutral_mb.y); - float3 relative_weighted_delta = float3( - direction.x, - -direction.x, - direction.y); - float3 physical_weighted_zero = - relative_weighted_zero * current_adaptive_state_lms; - float3 physical_weighted_delta = - relative_weighted_delta * current_adaptive_state_lms; - float denominator_zero = - physical_weighted_zero.x + physical_weighted_zero.y; - float denominator_delta = - physical_weighted_delta.x + physical_weighted_delta.y; - - float3x3 weighted_lms_to_target_rgb = - psycho25_WeightedLMSToRGBMatrix(target_gamut_mode); - float3 numerator_zero = mul( - weighted_lms_to_target_rgb, - physical_weighted_zero); - float3 numerator_delta = mul( - weighted_lms_to_target_rgb, - physical_weighted_delta); - - float support = PSYCHO25_LARGE; - - if (enforce_gamut_primaries) { - // target_yf*(N0 + r*N1) = 0 - float3 lower_denominator = target_yf * numerator_delta; - float3 lower_numerator = -target_yf * numerator_zero; - - if (abs(lower_denominator.x) > PSYCHO25_EPSILON) { - float radius = lower_numerator.x / lower_denominator.x; - if (radius > 0.f - && denominator_zero + radius * denominator_delta - > PSYCHO25_EPSILON) { - support = min(support, radius); - } - } - if (abs(lower_denominator.y) > PSYCHO25_EPSILON) { - float radius = lower_numerator.y / lower_denominator.y; - if (radius > 0.f - && denominator_zero + radius * denominator_delta - > PSYCHO25_EPSILON) { - support = min(support, radius); - } - } - if (abs(lower_denominator.z) > PSYCHO25_EPSILON) { - float radius = lower_numerator.z / lower_denominator.z; - if (radius > 0.f - && denominator_zero + radius * denominator_delta - > PSYCHO25_EPSILON) { - support = min(support, radius); - } - } - } - - if (enforce_gamut_peak) { - // target_yf*(N0 + r*N1) = peak*(D0 + r*D1) - float3 upper_denominator = - target_yf * numerator_delta - peak_value * denominator_delta; - float3 upper_numerator = - peak_value * denominator_zero - target_yf * numerator_zero; - - if (abs(upper_denominator.x) > PSYCHO25_EPSILON) { - float radius = upper_numerator.x / upper_denominator.x; - if (radius > 0.f - && denominator_zero + radius * denominator_delta - > PSYCHO25_EPSILON) { - support = min(support, radius); - } - } - if (abs(upper_denominator.y) > PSYCHO25_EPSILON) { - float radius = upper_numerator.y / upper_denominator.y; - if (radius > 0.f - && denominator_zero + radius * denominator_delta - > PSYCHO25_EPSILON) { - support = min(support, radius); - } - } - if (abs(upper_denominator.z) > PSYCHO25_EPSILON) { - float radius = upper_numerator.z / upper_denominator.z; - if (radius > 0.f - && denominator_zero + radius * denominator_delta - > PSYCHO25_EPSILON) { - support = min(support, radius); - } - } - } - - return max(support, 0.f); -} - -// Bounded generalized-Neutwo pressure response used only after the canonical -// target occupancy exceeds one. `pivot` is measured in excess occupancy -// q - 1, `contrast` controls pressure gain, and `h` controls the shoulder. -float psycho25_CanonicalCylinderPressure( - float occupancy, - float pivot, - float contrast, - float h) { - float excess = max(occupancy - 1.f, 0.f); - if (excess <= PSYCHO25_EPSILON) return 0.f; - - float safe_pivot = max(pivot, PSYCHO25_EPSILON); - float safe_contrast = max(contrast, PSYCHO25_EPSILON); - float safe_h = max(h, PSYCHO25_EPSILON); - float normalized_excess = excess / safe_pivot; - - // Equivalent generalized-Neutwo forms chosen by magnitude avoid inf/inf - // when stress inputs produce extremely large target occupancy. - if (normalized_excess >= 1.f) { - float inverse_power = pow( - normalized_excess, - -safe_contrast * safe_h); - return pow(1.f + inverse_power, -rcp(safe_h)); - } - float z = pow(normalized_excess, safe_contrast); - return z / pow(1.f + pow(z, safe_h), rcp(safe_h)); -} - -// Canonical-cylinder device-volume experiment. -// -// 1) Convert the authored midpoint/Graph point to (theta, rho, Yf). -// 2) Normalize radius by the exact selected-target support: -// q = rho / rho_max(theta, Yf). -// 3) Keep every q <= 1 point exactly unchanged. -// 4) For q > 1, map excess pressure to w in [0,1), then move both inward in -// canonical q and upward toward peak D65 white. -// 5) Re-evaluate rho_max at the raised Yf and reconstruct the same adaptive-MB -// hue direction with rho_out = q_out * rho_max(theta, Yf_out). -// -// `trade` selects the balance: 0 = inward-first, 1 = upward/white-first. -float3 psycho25_CompressCanonicalCylinderVolume( - float3 preferred_lms, - float3 current_adaptive_state_lms, - float peak_value, - int target_gamut_mode, - int gamut_enforcement, - float pressure_pivot, - float pressure_contrast, - float pressure_h, - float pressure_trade) { - const bool enforce_gamut_primaries = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; - const bool enforce_gamut_peak = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; - // This experiment is defined on the complete target RGB cube. Keep the - // independent lower-only / upper-only diagnostics on their existing paths - // rather than implicitly turning either one into full six-plane enforcement. - if (!enforce_gamut_primaries || !enforce_gamut_peak) { - return preferred_lms; - } - - float preferred_yf = psycho25_YfFromLMS(preferred_lms); - float target_peak_yf = psycho25_YfFromLMS( - psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode)); - if (!(preferred_yf > PSYCHO25_EPSILON) - || !(target_peak_yf > PSYCHO25_EPSILON)) { - return 0.f.xxx; - } - - // At or above the target's D65 peak cross-section the only full-cube point - // is peak white. This also avoids dividing by a vanishing radial support. - if (preferred_yf >= target_peak_yf * (1.f - PSYCHO25_EPSILON)) { - return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); - } - - float2 adapted_neutral_mb = - renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - float3 preferred_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - psycho25_ToAdaptiveRelativeWeightedLMS( - preferred_lms, - current_adaptive_state_lms)); - float2 preferred_offset = preferred_mb.xy - adapted_neutral_mb; - float preferred_radius2 = dot(preferred_offset, preferred_offset); - if (preferred_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { - // A neutral point only needs peak containment, already handled above. - return preferred_lms; - } - - float preferred_radius = sqrt(preferred_radius2); - float2 direction = preferred_offset / preferred_radius; - float radial_support = psycho25_TargetRadialSupportAtYf( - direction, - preferred_yf, - current_adaptive_state_lms, - adapted_neutral_mb, - peak_value, - target_gamut_mode, - gamut_enforcement); - if (radial_support >= PSYCHO25_LARGE * 0.5f) { - return preferred_lms; - } - if (radial_support <= PSYCHO25_EPSILON) { - return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); - } - - float occupancy = preferred_radius / radial_support; - if (occupancy <= 1.f) { - return preferred_lms; - } - - float pressure = psycho25_CanonicalCylinderPressure( - occupancy, - pressure_pivot, - pressure_contrast, - pressure_h); - float residual = max(1.f - pressure, 0.f); - float trade = saturate(pressure_trade); - - // Exact viewer mapping: - // trade=0: q contracts rapidly while Yf rises slowly. - // trade=1: Yf rises rapidly while q contracts slowly. - float inward_power = exp2(2.f - 4.f * trade); - float upward_power = exp2(-2.f + 4.f * trade); - float output_occupancy = pow(residual, inward_power); - float preferred_y = saturate(preferred_yf / target_peak_yf); - float output_y = 1.f - - (1.f - preferred_y) * pow(residual, upward_power); - if (output_y >= 1.f - PSYCHO25_EPSILON) { - return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); - } - float output_yf = output_y * target_peak_yf; - - float output_support = psycho25_TargetRadialSupportAtYf( - direction, - output_yf, - current_adaptive_state_lms, - adapted_neutral_mb, - peak_value, - target_gamut_mode, - gamut_enforcement); - float output_radius = output_occupancy * max(output_support, 0.f); - return psycho25_LMSFromHueDirectionAndYf( - direction, - output_radius, - output_yf, - current_adaptive_state_lms, - adapted_neutral_mb); -} - - -// Canonical Yf-cone device-volume experiment. -// -// This variant keeps the same exact star-volume occupancy as Canonical -// Cylinder, but Yf controls *where gamut pressure is spent*: -// - all pressure participates in radial containment; -// - only pressure weighted by pow(Yf / peakYf, bias_power) can raise Yf. -// -// Consequently, dark saturated colors are pulled inward toward the target -// radial support without being spuriously lifted toward peak white. As Yf -// approaches target peak, the same out-of-volume pressure progressively turns -// into whiteward motion and every positive hue can still converge on peak D65. -float3 psycho25_CompressCanonicalYfConeVolume( - float3 preferred_lms, - float3 current_adaptive_state_lms, - float peak_value, - int target_gamut_mode, - int gamut_enforcement, - float pressure_pivot, - float pressure_contrast, - float pressure_h, - float yf_bias_power) { - const bool enforce_gamut_primaries = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; - const bool enforce_gamut_peak = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; - if (!enforce_gamut_primaries || !enforce_gamut_peak) { - return preferred_lms; - } - - float preferred_yf = psycho25_YfFromLMS(preferred_lms); - float target_peak_yf = psycho25_YfFromLMS( - psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode)); - if (!(preferred_yf > PSYCHO25_EPSILON) - || !(target_peak_yf > PSYCHO25_EPSILON)) { - return 0.f.xxx; - } - if (preferred_yf >= target_peak_yf * (1.f - PSYCHO25_EPSILON)) { - return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); - } - - float2 adapted_neutral_mb = - renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - float3 preferred_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - psycho25_ToAdaptiveRelativeWeightedLMS( - preferred_lms, - current_adaptive_state_lms)); - float2 preferred_offset = preferred_mb.xy - adapted_neutral_mb; - float preferred_radius2 = dot(preferred_offset, preferred_offset); - if (preferred_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { - return preferred_lms; - } - - float preferred_radius = sqrt(preferred_radius2); - float2 direction = preferred_offset / preferred_radius; - float radial_support = psycho25_TargetRadialSupportAtYf( - direction, - preferred_yf, - current_adaptive_state_lms, - adapted_neutral_mb, - peak_value, - target_gamut_mode, - gamut_enforcement); - if (radial_support >= PSYCHO25_LARGE * 0.5f) { - return preferred_lms; - } - if (radial_support <= PSYCHO25_EPSILON) { - return 0.f.xxx; - } - - float occupancy = preferred_radius / radial_support; - if (occupancy <= 1.f) { - return preferred_lms; - } - - float pressure = psycho25_CanonicalCylinderPressure( - occupancy, - pressure_pivot, - pressure_contrast, - pressure_h); - - float preferred_y = saturate(preferred_yf / target_peak_yf); - float safe_yf_bias_power = max(yf_bias_power, PSYCHO25_EPSILON); - float white_bias = pow(preferred_y, safe_yf_bias_power); - - // Full gamut pressure contracts canonical radius. Dark colors therefore - // spend essentially all of their correction budget radially. - float radial_residual = max(1.f - pressure, 0.f); - float output_occupancy = radial_residual; - - // Only the Yf-weighted part of pressure may move the point upward. This is - // the conical bias: whiteward motion vanishes toward black and increases - // continuously toward peak. - float white_pressure = pressure * white_bias; - float output_y = preferred_y - + (1.f - preferred_y) * white_pressure; - if (output_y >= 1.f - PSYCHO25_EPSILON) { - return psycho25_LMSFromTargetRGB(peak_value.xxx, target_gamut_mode); - } - float output_yf = output_y * target_peak_yf; - - // The target cross-section changes after Yf motion, so convert the canonical - // occupancy back through the exact radial support at the new Yf. - float output_support = psycho25_TargetRadialSupportAtYf( - direction, - output_yf, - current_adaptive_state_lms, - adapted_neutral_mb, - peak_value, - target_gamut_mode, - gamut_enforcement); - float output_radius = output_occupancy * max(output_support, 0.f); - return psycho25_LMSFromHueDirectionAndYf( - direction, - output_radius, - output_yf, - current_adaptive_state_lms, - adapted_neutral_mb); -} - -Psycho25ConeResponseState psycho25_BuildConeResponseState( - float3 contrast_lms, - Psycho25ConeResponseParameters parameters) { - float3 contrast_ratio = contrast_lms / parameters.anchor_out; - - Psycho25ConeResponseState state; - state.compression_exponent = parameters.compression_exponent; - state.input_response_exponent = parameters.input_response_exponent; - state.encoded_peak_offset = parameters.encoded_peak_offset; - state.encoded_response = renodx::math::SignPow( - contrast_ratio, - parameters.compression_exponent - * parameters.encoded_response_power); - return state; -} - -Psycho25ConeResponseState psycho25_BuildConeResponseState( - float3 contrast_lms, - float3 anchor_out, - float3 lms_peak, - float contrast_power, - float compression_power, - float encoded_response_power) { - return psycho25_BuildConeResponseState( - contrast_lms, - psycho25_PrepareConeResponseParameters( - anchor_out, - lms_peak, - contrast_power, - compression_power, - encoded_response_power)); -} - -float3 psycho25_CompressionRolloffSignedPerCone( - float3 signed_contrast_lms, - Psycho25ConeResponseParameters parameters) { - Psycho25ConeResponseState response_state = - psycho25_BuildConeResponseState( - signed_contrast_lms, - parameters); - return renodx::math::SignPow( - response_state.encoded_response - / (abs(response_state.encoded_response) - + response_state.encoded_peak_offset), - parameters.inverse_compression_power); -} - -float3 psycho25_CompressionRolloffPerCone( - float3 contrast_lms, - float3 anchor_out, - float3 lms_peak, - float contrast_power, - float compression_power, - float encoded_response_power) { - return psycho25_CompressionRolloffSignedPerCone( - contrast_lms, - psycho25_PrepareConeResponseParameters( - anchor_out, - lms_peak, - contrast_power, - compression_power, - encoded_response_power)); -} - -float psycho25_CompressionRolloffScalar( - float input_value, - float anchor_out, - float peak_value, - float compression_power) { - if (input_value <= 0.f) return 0.f; - float anchor_over_peak = anchor_out / peak_value; - float anchor_peak_power = pow( - anchor_over_peak, - compression_power); - float compression_slope_norm = 1.f - anchor_peak_power; - float encoded_peak_offset = rcp(anchor_peak_power) - 1.f; - float input_response_power = compression_power - / compression_slope_norm; - float log_offset_over_input = log(max(encoded_peak_offset, 1e-30f)) - - input_response_power - * log(input_value / anchor_out); - float normalized_response = rcp( - 1.f + exp(clamp(log_offset_over_input, -80.f, 80.f))); - return peak_value * pow( - normalized_response, - rcp(compression_power)); -} - -float3 psycho25_ApplyPostTargetCompression( - float3 target_rgb, - float3 anchor_target_rgb, - float peak_value, - float compression_power, - int gamut_enforcement, - int post_compression_mode) { - const bool enforce_gamut_primaries = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; - const bool enforce_gamut_peak = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; - if (enforce_gamut_primaries) { - target_rgb = max(target_rgb, 0.f.xxx); - } - if (!enforce_gamut_peak) return target_rgb; - - if (post_compression_mode == PSYCHO25_POST_COMPRESSION_PER_CHANNEL - || post_compression_mode - == PSYCHO25_POST_COMPRESSION_SOURCE_MB_PER_CHANNEL) { - float3 positive_rgb = max(target_rgb, 0.f.xxx); - float3 safe_anchor = clamp( - anchor_target_rgb, - PSYCHO25_EPSILON.xxx, - (peak_value - PSYCHO25_EPSILON).xxx); - float3 compressed_rgb = float3( - psycho25_CompressionRolloffScalar( - positive_rgb.x, - safe_anchor.x, - peak_value, - compression_power), - psycho25_CompressionRolloffScalar( - positive_rgb.y, - safe_anchor.y, - peak_value, - compression_power), - psycho25_CompressionRolloffScalar( - positive_rgb.z, - safe_anchor.z, - peak_value, - compression_power)); - return min(target_rgb, 0.f.xxx) + compressed_rgb; - } - - float max_target_channel = max( - abs(target_rgb.x), - max(abs(target_rgb.y), abs(target_rgb.z))); - if (max_target_channel <= PSYCHO25_EPSILON) return target_rgb; - float anchor_max_channel = max( - abs(anchor_target_rgb.x), - max(abs(anchor_target_rgb.y), abs(anchor_target_rgb.z))); - float compressed_max_channel = psycho25_CompressionRolloffScalar( - max_target_channel, - clamp( - anchor_max_channel, - PSYCHO25_EPSILON, - peak_value - PSYCHO25_EPSILON), - peak_value, - compression_power); - return target_rgb * (compressed_max_channel / max_target_channel); -} - -float3 psycho25_RestoreSourceAdaptiveMBDirection( - float3 candidate_lms, - float3 source_lms, - float3 current_adaptive_state_lms) { - float3 candidate_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - psycho25_ToAdaptiveRelativeWeightedLMS( - candidate_lms, - current_adaptive_state_lms)); - float3 source_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - psycho25_ToAdaptiveRelativeWeightedLMS( - source_lms, - current_adaptive_state_lms)); - float2 adapted_neutral_mb = - renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - float2 candidate_offset = candidate_mb.xy - adapted_neutral_mb; - float2 source_offset = source_mb.xy - adapted_neutral_mb; - float candidate_radius2 = dot(candidate_offset, candidate_offset); - float source_radius2 = dot(source_offset, source_offset); - if (candidate_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON - || source_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { - return candidate_lms; - } - - candidate_mb.xy = adapted_neutral_mb - + source_offset * rsqrt(source_radius2) * sqrt(candidate_radius2); - return psycho25_LMSFromAdaptiveMB( - candidate_mb, - current_adaptive_state_lms); -} - - float3 psycho25_RestoreSourceBT709ResidualDirection( - float3 candidate_lms, - float3 source_lms, - float peak_value, - int target_gamut_mode, - int gamut_enforcement) { - float3 candidate_bt709 = renodx::color::bt709::from::LMS(candidate_lms); - float3 source_bt709 = renodx::color::bt709::from::LMS(source_lms); - float candidate_y = renodx::color::y::from::BT709(candidate_bt709); - float source_y = renodx::color::y::from::BT709(source_bt709); - float3 candidate_residual = candidate_bt709 - candidate_y.xxx; - float3 source_residual = source_bt709 - source_y.xxx; - float candidate_residual2 = dot(candidate_residual, candidate_residual); - float source_residual2 = dot(source_residual, source_residual); - if (candidate_residual2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON - || source_residual2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { - return candidate_lms; - } - - float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( - renodx::color::lms::from::BT709(candidate_y.xxx), - target_gamut_mode); - float3 candidate_target_rgb = psycho25_TargetRGBFromLMS( - renodx::color::lms::from::BT709( - candidate_y.xxx - + source_residual - * sqrt(candidate_residual2 / source_residual2)), - target_gamut_mode); - float3 target_residual = candidate_target_rgb - neutral_target_rgb; - float residual_scale = 1.f; - if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0) { - float3 lower_support = neutral_target_rgb - / max(-target_residual, PSYCHO25_EPSILON.xxx); - residual_scale = min( - residual_scale, - min(lower_support.x, min(lower_support.y, lower_support.z))); - } - if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0) { - float3 upper_support = (peak_value.xxx - neutral_target_rgb) - / max(target_residual, PSYCHO25_EPSILON.xxx); - residual_scale = min( - residual_scale, - min(upper_support.x, min(upper_support.y, upper_support.z))); - } - return psycho25_LMSFromTargetRGB( - neutral_target_rgb + target_residual * saturate(residual_scale), - target_gamut_mode); - } - -float3 psycho25_GamutCompressLMSBoundAdaptive( - float3 lms_input, - float3 current_adaptive_state_lms, - int target_gamut_mode, - float strength) { - float3 lms_weighted_relative = - psycho25_ToAdaptiveRelativeWeightedLMS( - lms_input, - current_adaptive_state_lms); - float3 lms_weighted_relative_out = - renodx::color::gamut::GamutCompressWeightedLMSCoreRGBBoundFromAdaptiveWeightedInput( - lms_weighted_relative, - current_adaptive_state_lms, - target_gamut_mode == 0 - ? renodx::color::macleod_boynton::BT709_TO_LMS_WEIGHTED_MAT - : renodx::color::macleod_boynton::BT2020_TO_LMS_WEIGHTED_MAT, - strength); - return renodx::color::macleod_boynton::UnweighLMS( - psycho25_FromAdaptiveRelativeWeightedLMS( - lms_weighted_relative_out, - current_adaptive_state_lms)); -} - - -bool psycho25_TargetRGBInsideEnabledHull( - float3 target_rgb, - float peak_value, - int gamut_enforcement) { - if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0 - && min(target_rgb.x, min(target_rgb.y, target_rgb.z)) < 0.f) { - return false; - } - if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0 - && max(target_rgb.x, max(target_rgb.y, target_rgb.z)) > peak_value) { - return false; - } - return true; -} - -// Bounded adaptation-relative ACHROMATIC contrast used only as a scalar -// bookkeeping metric after the ideal PsychoV result has been completed. -// -// The previous RMS-per-cone metric allowed chromatic loss to masquerade as a -// white/brightness deficit. Extremely saturated reds could therefore drift too -// far toward white simply because a legal fit removed adaptive-MB radius. -// -// Instead, the post-fit white budget is now driven only by Yf: -// -// A(Yf) = (Yf - Yf_adapt) / (abs(Yf) + abs(Yf_adapt)) -// -// This stays finite at black, is explicitly aligned with PsychoV's weighted -// LMS/Yf achromatic axis, and does not convert chromatic loss into white. -float psycho25_AchromaticYfContrast( - float3 lms, - float3 current_adaptive_state_lms) { - float signal_yf = psycho25_SignedYfFromLMS(lms); - float adapt_yf = psycho25_SignedYfFromLMS(current_adaptive_state_lms); - float safe_adapt_yf = max(abs(adapt_yf), PSYCHO25_EPSILON); - return (signal_yf - adapt_yf) - / (abs(signal_yf) + safe_adapt_yf); -} - -// Exact same-adaptive-MB-hue fit of a completed PsychoV point into the enabled -// selected-target RGB planes. -// -// The preferred physical Yf is retained whenever that Yf has a nonempty target -// cross-section. Only adaptive-MB radius is shortened, using the exact -// closed-form six-plane support already used by Test25: -// -// rho_out = min(rho_ideal, rho_max(theta, Yf)) -// -// No low-Y support approximation is used. In particular, rho_max does NOT -// collapse toward zero merely because Yf approaches black; black is reached by -// Yf -> 0 while chromaticity may remain saturated. If upper planes are enabled -// and Yf reaches the target D65 peak cross-section, peak white is the only -// legal full-cube point. -float3 psycho25_ExactAdaptiveMBTargetFit( - float3 ideal_lms, - float3 current_adaptive_state_lms, - float peak_value, - int target_gamut_mode, - int gamut_enforcement) { - if (gamut_enforcement == PSYCHO25_GAMUT_ENFORCEMENT_NONE) { - return ideal_lms; - } - - float3 ideal_target_rgb = psycho25_TargetRGBFromLMS( - ideal_lms, - target_gamut_mode); - if (psycho25_TargetRGBInsideEnabledHull( - ideal_target_rgb, - peak_value, - gamut_enforcement)) { - return ideal_lms; - } - - const bool enforce_gamut_primaries = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; - const bool enforce_gamut_peak = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; - - float ideal_yf = psycho25_SignedYfFromLMS(ideal_lms); - if (!(ideal_yf > PSYCHO25_EPSILON)) { - if (enforce_gamut_primaries) { - return 0.f.xxx; - } - // Without lower-plane enforcement there is no positive-Yf ray constraint. - // Apply only the enabled upper planes as a numerical target-space fallback. - float3 target_rgb = ideal_target_rgb; - if (enforce_gamut_peak) { - target_rgb = min(target_rgb, peak_value.xxx); - } - return psycho25_LMSFromTargetRGB(target_rgb, target_gamut_mode); - } - - float3 target_peak_lms = psycho25_LMSFromTargetRGB( - peak_value.xxx, - target_gamut_mode); - float target_peak_yf = psycho25_SignedYfFromLMS(target_peak_lms); - if (enforce_gamut_peak - && ideal_yf >= target_peak_yf * (1.f - PSYCHO25_EPSILON)) { - return target_peak_lms; - } - - float2 adapted_neutral_mb = - renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - float3 ideal_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - psycho25_ToAdaptiveRelativeWeightedLMS( - ideal_lms, - current_adaptive_state_lms)); - float2 ideal_offset = ideal_mb.xy - adapted_neutral_mb; - float ideal_radius2 = dot(ideal_offset, ideal_offset); - - // Neutral points have no radial degree of freedom. If one is still outside, - // only the enabled target planes can resolve it. - if (ideal_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { - float3 target_rgb = ideal_target_rgb; - if (enforce_gamut_primaries) { - target_rgb = max(target_rgb, 0.f.xxx); - } - if (enforce_gamut_peak) { - target_rgb = min(target_rgb, peak_value.xxx); - } - return psycho25_LMSFromTargetRGB(target_rgb, target_gamut_mode); - } - - float ideal_radius = sqrt(ideal_radius2); - float2 direction = ideal_offset / ideal_radius; - float radial_support = psycho25_TargetRadialSupportAtYf( - direction, - ideal_yf, - current_adaptive_state_lms, - adapted_neutral_mb, - peak_value, - target_gamut_mode, - gamut_enforcement); - - if (radial_support >= PSYCHO25_LARGE * 0.5f - || ideal_radius <= radial_support) { - return ideal_lms; - } - - float3 legal_lms = psycho25_LMSFromHueDirectionAndYf( - direction, - max(radial_support, 0.f), - ideal_yf, - current_adaptive_state_lms, - adapted_neutral_mb); - - // Exact support should already be legal. This final clamp covers only - // floating-point residue at a target plane. - float3 legal_target_rgb = psycho25_TargetRGBFromLMS( - legal_lms, - target_gamut_mode); - if (enforce_gamut_primaries) { - legal_target_rgb = max(legal_target_rgb, 0.f.xxx); - } - if (enforce_gamut_peak) { - legal_target_rgb = min(legal_target_rgb, peak_value.xxx); - } - return psycho25_LMSFromTargetRGB( - legal_target_rgb, - target_gamut_mode); -} - -// Post-ideal lost-contrast fit. -// -// 1) Complete Test25's ordinary physical/MIDPOINT result. -// 2) Fit that result to the exact selected-target six-plane support while -// retaining its adaptive-MB hue and Yf whenever the cross-section exists. -// 3) Measure only the bounded ACHROMATIC contrast lost by that legal fit: -// -// dA = max(A_ideal - A_legal, 0), -// -// where A is the Yf-based adaptation-relative contrast above. -// 4) Convert dA to a bounded Neutwo-like pressure. -// 5) Permit that pressure to become whiteward motion only in proportion to the -// square of physical Yf / target-peak Yf. -// -// Thus gamut fitting itself does not repower LMS ratios. Lost chromatic radius -// does not become white. Near black, the whiteward term vanishes quadratically -// and the exact same-hue legal result is retained. At high Yf, lost achromatic -// contrast may be spent along the legal point -> peak-D65-white segment, which -// remains inside the convex target RGB cube. -float3 psycho25_ApplyAdaptiveContrastFitLinearWhiteLegacy( - float3 ideal_lms, - float3 current_adaptive_state_lms, - float peak_value, - int target_gamut_mode, - int gamut_enforcement) { - if (gamut_enforcement == PSYCHO25_GAMUT_ENFORCEMENT_NONE) { - return ideal_lms; - } - - float3 legal_lms = psycho25_ExactAdaptiveMBTargetFit( - ideal_lms, - current_adaptive_state_lms, - peak_value, - target_gamut_mode, - gamut_enforcement); - - float ideal_contrast = psycho25_AchromaticYfContrast( - ideal_lms, - current_adaptive_state_lms); - float legal_contrast = psycho25_AchromaticYfContrast( - legal_lms, - current_adaptive_state_lms); - float lost_contrast = max(ideal_contrast - legal_contrast, 0.f); - if (lost_contrast <= PSYCHO25_EPSILON) { - return legal_lms; - } - - const bool enforce_gamut_peak = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; - if (!enforce_gamut_peak) { - // Without an upper hull there is no defined target-white destination for - // the lost achromatic budget. Keep the exact same-hue fit. - return legal_lms; - } - - float3 target_peak_lms = psycho25_LMSFromTargetRGB( - peak_value.xxx, - target_gamut_mode); - float peak_contrast = psycho25_AchromaticYfContrast( - target_peak_lms, - current_adaptive_state_lms); - float available_contrast = max( - peak_contrast - legal_contrast, - PSYCHO25_EPSILON); - float normalized_loss = lost_contrast / available_contrast; - - // h=2 generalized-Neutwo occupancy: bounded [0,1), identity-like for small - // normalized loss and asymptotic under extreme out-of-hull stress. - float loss_pressure = normalized_loss - * rsqrt(1.f + normalized_loss * normalized_loss); - - float legal_yf = max(psycho25_SignedYfFromLMS(legal_lms), 0.f); - float target_peak_yf = max( - psycho25_SignedYfFromLMS(target_peak_lms), - PSYCHO25_EPSILON); - float yf_fraction = saturate(legal_yf / target_peak_yf); - float white_pressure = loss_pressure * yf_fraction * yf_fraction; - - float3 legal_target_rgb = psycho25_TargetRGBFromLMS( - legal_lms, - target_gamut_mode); - float3 output_target_rgb = lerp( - legal_target_rgb, - peak_value.xxx, - white_pressure); - - // Both endpoints are legal target-cube points, so this convex interpolation - // is legal by construction. Clamp only for floating-point residue. - if ((gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0) { - output_target_rgb = max(output_target_rgb, 0.f.xxx); - } - output_target_rgb = min(output_target_rgb, peak_value.xxx); - return psycho25_LMSFromTargetRGB( - output_target_rgb, - target_gamut_mode); -} - -float3 psycho25_ApplyIndependentPostCompression( - float3 contrast_lms, - float3 source_lms, - float3 anchor_out, - float3 current_adaptive_state_lms, - float peak_value, - float compression_power, - int target_gamut_mode, - int gamut_enforcement, - int post_compression_mode) { - if (post_compression_mode == PSYCHO25_POST_COMPRESSION_DIRECT) { - return contrast_lms; - } - - float3 post_lms = contrast_lms; - if (post_compression_mode - == PSYCHO25_POST_COMPRESSION_SOURCE_MB_PER_CHANNEL - || post_compression_mode - == PSYCHO25_POST_COMPRESSION_SOURCE_MB_SOFT_MAX) { - post_lms = psycho25_RestoreSourceAdaptiveMBDirection( - post_lms, - source_lms, - current_adaptive_state_lms); - } - - const bool enforce_gamut_primaries = - (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; - if (enforce_gamut_primaries) { - if (post_compression_mode - == PSYCHO25_POST_COMPRESSION_ADAPTIVE_MB_HARD_MAX) { - float3 post_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - psycho25_ToAdaptiveRelativeWeightedLMS( - post_lms, - current_adaptive_state_lms)); - post_mb = psycho25_PullBackAdaptiveMBToTargetLowerPlanes( - post_mb, - renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy, - current_adaptive_state_lms, - target_gamut_mode); - post_lms = psycho25_LMSFromAdaptiveMB( - post_mb, - current_adaptive_state_lms); - } else if (post_compression_mode - == PSYCHO25_POST_COMPRESSION_ADAPTIVE_MB_SOFT_MAX - || post_compression_mode - == PSYCHO25_POST_COMPRESSION_SOURCE_MB_SOFT_MAX) { - // Match PsychoV17's final device-map helper: adaptive-relative weighted - // LMS, the selected target-primary triangle, and strength 1. - post_lms = psycho25_GamutCompressLMSBoundAdaptive( - post_lms, - current_adaptive_state_lms, - target_gamut_mode, - 1.f); - } else if (post_compression_mode - == PSYCHO25_POST_COMPRESSION_FIXED_D65_SOFT_MAX) { - post_lms = target_gamut_mode == 0 - ? renodx::color::gamut::GamutCompressLMSBoundBT709(post_lms, 1.f) - : renodx::color::gamut::GamutCompressLMSBoundBT2020(post_lms, 1.f); - } - } - - float3 post_target_rgb = psycho25_TargetRGBFromLMS( - post_lms, - target_gamut_mode); - post_target_rgb = psycho25_ApplyPostTargetCompression( - post_target_rgb, - psycho25_TargetRGBFromLMS(anchor_out, target_gamut_mode), - peak_value, - compression_power, - gamut_enforcement, - post_compression_mode); - return psycho25_LMSFromTargetRGB( - post_target_rgb, - target_gamut_mode); -} - -float psycho25_EvaluateRawPerChannelHueShift( - float source_hue_angle, - Psycho25HueEvaluationContext context) { - float2 source_direction = - float2(cos(source_hue_angle), sin(source_hue_angle)); - float3 candidate_source_lms = - psycho25_LMSFromHueDirectionAndYf( - source_direction, - context.source_radius, - context.source_target_yf, - context.current_adaptive_state_lms, - context.adapted_neutral_mb); - - float3 contrast_lms = psycho25_ApplyContrastResponse( - candidate_source_lms, - context.anchor_in, - context.anchor_out, - context.contrast_power, - context.observer_gamut_mode); - float3 candidate_guidance_lms = context.guidance_lms_peak - * psycho25_CompressionRolloffSignedPerCone( - contrast_lms, - context.guidance_cone_response); - float2 compressed_direction = - psycho25_AdaptiveMBDirection( - candidate_guidance_lms, - context.current_adaptive_state_lms, - context.adapted_neutral_mb); - if (dot(compressed_direction, compressed_direction) - <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) return 0.f; - - return atan2( - psycho25_Cross2(source_direction, compressed_direction), - dot(source_direction, compressed_direction)); -} - -Psycho25HueGeometry psycho25_FindHueGeometry( - Psycho25HueSection section, - Psycho25HueEvaluationContext context) { - float step = (section.end - section.start) - / float(PSYCHO25_HUE_PEAK_SCAN_INTERVALS); - - float best_angle = section.midpoint; - float best_shift = psycho25_EvaluateRawPerChannelHueShift( - best_angle, - context); - float best_magnitude = abs(best_shift); - - [loop] - for (uint scan = 0u; - scan <= PSYCHO25_HUE_PEAK_SCAN_INTERVALS; - ++scan) { - if (scan == PSYCHO25_HUE_PEAK_SCAN_INTERVALS / 2u) continue; - float angle = section.start + step * float(scan); - float shift = psycho25_EvaluateRawPerChannelHueShift( - angle, - context); - if (abs(shift) > best_magnitude) { - best_angle = angle; - best_shift = shift; - best_magnitude = abs(shift); - } - } - - float lo = max(section.start, best_angle - step); - float hi = min(section.end, best_angle + step); - static const float golden = 0.6180339887498948482f; - float x1 = hi - golden * (hi - lo); - float x2 = lo + golden * (hi - lo); - float shift1 = psycho25_EvaluateRawPerChannelHueShift(x1, context); - float shift2 = psycho25_EvaluateRawPerChannelHueShift(x2, context); - float y1 = abs(shift1); - float y2 = abs(shift2); - - [loop] - for (uint iteration = 0u; - iteration < PSYCHO25_HUE_PEAK_REFINE_ITERATIONS; - ++iteration) { - if (y1 < y2) { - lo = x1; - x1 = x2; - y1 = y2; - shift1 = shift2; - x2 = lo + golden * (hi - lo); - shift2 = psycho25_EvaluateRawPerChannelHueShift(x2, context); - y2 = abs(shift2); - } else { - hi = x2; - x2 = x1; - y2 = y1; - shift2 = shift1; - x1 = hi - golden * (hi - lo); - shift1 = psycho25_EvaluateRawPerChannelHueShift(x1, context); - y1 = abs(shift1); - } - } - - float refined_angle = y1 >= y2 ? x1 : x2; - float refined_shift = y1 >= y2 ? shift1 : shift2; - if (abs(refined_shift) > best_magnitude) { - best_angle = refined_angle; - best_shift = refined_shift; - best_magnitude = abs(refined_shift); - } - - float peak_offset = best_angle - section.midpoint; - Psycho25HueGeometry geometry; - geometry.peak_angle = best_angle; - geometry.peak_shift = best_shift; - geometry.active = best_magnitude > PSYCHO25_EPSILON ? 1u : 0u; - geometry.axis_slope = geometry.active != 0u - ? (abs(peak_offset) > PSYCHO25_EPSILON - ? best_shift / peak_offset - : (best_shift < 0.f ? -PSYCHO25_LARGE : PSYCHO25_LARGE)) - : -2.2f; - geometry.maximum_ordered_amplitude = 1.f; - if (geometry.active != 0u - && abs(peak_offset) > PSYCHO25_EPSILON - && section.index == 2u) { - // The raw +S-to--L field can form a sharp Yf- and purity-dependent cusp. - // Its amplitude-1 endpoint remains available, but the authored field must - // not fold hue phase. Probe both cusp sides and cap only this pin interval's - // effective amplitude with margin. For the oblique inverse - // x = t - (1 - A) r(t) / s, y = x + A r(t), - // the ordered-phase coefficient is A - (1 - A) / s. - geometry.axis_slope = min( - geometry.axis_slope, - PSYCHO25_HUE_REVERSAL_AXIS_SLOPE_LIMIT); - float derivative_step = max( - step / PSYCHO25_HUE_ORDER_DERIVATIVE_PROBE_DIVISOR, - PSYCHO25_EPSILON); - float left_angle = max(section.start, best_angle - derivative_step); - float right_angle = min(section.end, best_angle + derivative_step); - float left_shift = psycho25_EvaluateRawPerChannelHueShift( - left_angle, - context); - float right_shift = psycho25_EvaluateRawPerChannelHueShift( - right_angle, - context); - float left_derivative = renodx::math::DivideSafe( - best_shift - left_shift, - best_angle - left_angle, - 0.f); - float right_derivative = renodx::math::DivideSafe( - right_shift - best_shift, - right_angle - best_angle, - 0.f); - float minimum_raw_derivative = min(left_derivative, right_derivative); - if (minimum_raw_derivative < -PSYCHO25_EPSILON) { - float maximum_raw_coefficient = PSYCHO25_HUE_ORDER_SAFETY - / -minimum_raw_derivative; - float inverse_axis_slope = 1.f / geometry.axis_slope; - geometry.maximum_ordered_amplitude = saturate( - (maximum_raw_coefficient + inverse_axis_slope) - / (1.f + inverse_axis_slope)); - } - } - return geometry; -} - -float psycho25_ForwardMappedHue( - float curve_parameter, - float amplitude, - float axis_slope, - Psycho25HueEvaluationContext context) { - float shift = psycho25_EvaluateRawPerChannelHueShift( - curve_parameter, - context); - // The graph-space operation has the closed form - // x' = x - (1 - A) * y / slope, y' = A * y. - // Inversion needs only X; the final consumed shift is its direct Y form. - return curve_parameter - (1.f - amplitude) * shift / axis_slope; -} - -float psycho25_SolveSextantHueShift( - Psycho25HueSection section, - Psycho25HueGeometry geometry, - float amplitude, - Psycho25HueEvaluationContext context) { - if (amplitude <= PSYCHO25_EPSILON || geometry.active == 0u) return 0.f; - if (min( - section.source_unwrapped - section.start, - section.end - section.source_unwrapped) - <= PSYCHO25_EPSILON) return 0.f; - - if (amplitude >= 1.f - PSYCHO25_EPSILON) { - return psycho25_EvaluateRawPerChannelHueShift( - section.source_unwrapped, - context); - } - - // The oblique graph transform can make mapped X locally nonmonotonic even - // when the final hue phase remains ordered. A whole-interval bisection then - // changes between distant roots under tiny input perturbations. Bracket all - // sign-changing roots at a fixed resolution and invert the one nearest the - // requested source phase, which is the local branch connected to the - // amplitude-1 identity transform. - float lo = section.start; - float hi = section.end; - float lo_value = psycho25_ForwardMappedHue( - lo, - amplitude, - geometry.axis_slope, - context) - - section.source_unwrapped; - float best_distance = PSYCHO25_LARGE; - float previous_parameter = lo; - float previous_value = lo_value; - [loop] - for (uint scan = 1u; - scan <= PSYCHO25_HUE_INVERSE_BRACKET_INTERVALS; - ++scan) { - float parameter = lerp( - section.start, - section.end, - float(scan) / float(PSYCHO25_HUE_INVERSE_BRACKET_INTERVALS)); - float value = psycho25_ForwardMappedHue( - parameter, - amplitude, - geometry.axis_slope, - context) - - section.source_unwrapped; - if (previous_value * value <= 0.f) { - float estimate_fraction = saturate(renodx::math::DivideSafe( - -previous_value, - value - previous_value, - 0.5f)); - float estimated_parameter = lerp( - previous_parameter, - parameter, - estimate_fraction); - float distance = abs( - estimated_parameter - section.source_unwrapped); - if (distance < best_distance) { - lo = previous_parameter; - hi = parameter; - lo_value = previous_value; - best_distance = distance; - } - } - previous_parameter = parameter; - previous_value = value; - } - - [loop] - for (uint iteration = 0u; - iteration < PSYCHO25_HUE_INVERSE_ITERATIONS; - ++iteration) { - float midpoint = 0.5f * (lo + hi); - float midpoint_value = psycho25_ForwardMappedHue( - midpoint, - amplitude, - geometry.axis_slope, - context) - - section.source_unwrapped; - if ((lo_value < 0.f) == (midpoint_value < 0.f)) { - lo = midpoint; - lo_value = midpoint_value; - } else { - hi = midpoint; - } - } - - float curve_parameter = 0.5f * (lo + hi); - float raw_shift = psycho25_EvaluateRawPerChannelHueShift( - curve_parameter, - context); - return amplitude * raw_shift; -} - -Psycho25AdaptiveMBTrajectory psycho25_BuildAdaptiveMBTrajectory( - float3 physical_magnitude_lms, - float3 guidance_direction_lms, - float3 direction_source_lms, - float3 current_adaptive_state_lms, - float3 anchor_in, - float3 anchor_out, - float3 guidance_lms_peak, - float contrast_power, - Psycho25ConeResponseParameters guidance_cone_response, - int hue_method, - int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { - float3 magnitude_relative_weighted = - psycho25_ToAdaptiveRelativeWeightedLMS( - physical_magnitude_lms, - current_adaptive_state_lms); - float3 magnitude_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - magnitude_relative_weighted); - float2 adapted_neutral_mb = - renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - Psycho25AdaptiveMBTrajectory trajectory; - trajectory.authored_mb = magnitude_mb; - trajectory.hue_applied = 0u; - - float3 source_relative_weighted = - psycho25_ToAdaptiveRelativeWeightedLMS( - direction_source_lms, - current_adaptive_state_lms); - float3 source_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - source_relative_weighted); - float3 compressed_direction_relative_weighted = - psycho25_ToAdaptiveRelativeWeightedLMS( - guidance_direction_lms, - current_adaptive_state_lms); - float3 compressed_direction_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - compressed_direction_relative_weighted); - - float2 magnitude_offset = magnitude_mb.xy - adapted_neutral_mb; - float2 source_offset = source_mb.xy - adapted_neutral_mb; - float2 compressed_direction_offset = - compressed_direction_mb.xy - adapted_neutral_mb; - float magnitude_radius2 = dot(magnitude_offset, magnitude_offset); - float source_radius2 = dot(source_offset, source_offset); - float compressed_direction_radius2 = dot( - compressed_direction_offset, - compressed_direction_offset); - if (magnitude_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON - || source_radius2 <= PSYCHO25_EPSILON * PSYCHO25_EPSILON - || compressed_direction_radius2 - <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) { - return trajectory; - } - - float magnitude_radius = sqrt(magnitude_radius2); - float source_radius = sqrt(source_radius2); - float2 source_direction = source_offset / source_radius; - if (hue_method == PSYCHO25_HUE_METHOD_FAST_60) { - // The fixed approximately 60-degree hue-graph assumption reduces the 50% - // operation to the angular midpoint between the source and current raw - // per-channel-compressed directions. Normalizing their linear midpoint is - // exact for equal-weight unit directions and avoids all graph searches. - float2 compressed_direction = compressed_direction_offset - * rsqrt(compressed_direction_radius2); - float2 output_direction = lerp( - source_direction, - compressed_direction, - 1.f - PSYCHO25_HUE_AMPLITUDE); - float output_direction2 = dot(output_direction, output_direction); - if (output_direction2 - <= PSYCHO25_EPSILON * PSYCHO25_EPSILON) return trajectory; - output_direction *= rsqrt(output_direction2); - trajectory.authored_mb = float3( - adapted_neutral_mb + output_direction * magnitude_radius, - magnitude_mb.z); - trajectory.hue_applied = 1u; - return trajectory; - } - - float source_hue_angle = atan2(source_direction.y, source_direction.x); - Psycho25HueEvaluationContext context = psycho25_PrepareHueEvaluationContext( - guidance_cone_response, - current_adaptive_state_lms, - anchor_in, - anchor_out, - guidance_lms_peak, - adapted_neutral_mb, - source_radius, - psycho25_YfFromLMS(direction_source_lms), - contrast_power, - observer_gamut_mode); - - float2 axis_l = psycho25_IsolatedConeDisplacementAxis( - current_adaptive_state_lms, - adapted_neutral_mb, - 0u); - float2 axis_m = psycho25_IsolatedConeDisplacementAxis( - current_adaptive_state_lms, - adapted_neutral_mb, - 1u); - float2 axis_s = psycho25_IsolatedConeDisplacementAxis( - current_adaptive_state_lms, - adapted_neutral_mb, - 2u); - Psycho25HueSection section = psycho25_HuePinIntervalForAngle( - source_hue_angle, - axis_l, - axis_m, - axis_s); - Psycho25HueGeometry geometry = psycho25_FindHueGeometry( - section, - context); - float hue_shift = psycho25_SolveSextantHueShift( - section, - geometry, - min( - PSYCHO25_HUE_AMPLITUDE, - geometry.maximum_ordered_amplitude), - context); - float output_hue_angle = source_hue_angle + hue_shift; - float2 output_direction = - float2(cos(output_hue_angle), sin(output_hue_angle)); - trajectory.authored_mb = float3( - adapted_neutral_mb + output_direction * magnitude_radius, - magnitude_mb.z); - trajectory.hue_applied = 1u; - return trajectory; -} - -// Both output branches use the same prepared cone-response state. The direct -// branch returns its saturation shoulder; the gamut-active branch retains only -// its graph-solved adaptive-MB trajectory before target-plane -// compression. -float3 psycho25_ApplyPhysicalPerConePath( - float3 desired_lms, - float3 direction_source_lms, - float3 current_adaptive_state_lms, - float3 anchor_in, - float3 anchor_out, - float3 physical_lms_peak, - float contrast_power, - Psycho25ConeResponseParameters physical_cone_response, - int hue_method, - int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { - float3 physical_compressed_lms = physical_lms_peak - * psycho25_CompressionRolloffSignedPerCone( - desired_lms, - physical_cone_response); - Psycho25AdaptiveMBTrajectory trajectory = - psycho25_BuildAdaptiveMBTrajectory( - physical_compressed_lms, - physical_compressed_lms, - direction_source_lms, - current_adaptive_state_lms, - anchor_in, - anchor_out, - physical_lms_peak, - contrast_power, - physical_cone_response, - hue_method, - observer_gamut_mode); - if (trajectory.hue_applied == 0u) return physical_compressed_lms; - float3 authored_lms = psycho25_LMSFromAdaptiveMB( - trajectory.authored_mb, - current_adaptive_state_lms); - return authored_lms * renodx::math::DivideSafe( - psycho25_YfFromLMS(physical_compressed_lms), - psycho25_YfFromLMS(authored_lms), - 1.f); -} - - -// Post-ideal contrast fit that follows Test25's own physical/MIDPOINT path. -// -// The exact same-hue target fit first removes only the adaptive-MB radius that -// the selected RGB cube cannot represent at the completed physical Yf. The -// removed chromatic fraction is NOT treated as equal-energy white. Instead it -// contributes to a trajectory-advance pressure only on the high side of the -// adapted state: -// -// chroma_loss = (rho_ideal - rho_legal) / rho_ideal -// yf_gate = saturate((Yf_legal - Yf_adapt) / (Yf_peak - Yf_adapt)) -// -// Genuine lost achromatic Yf contrast contributes independently. Their smooth -// union is bounded with the h=2 Neutwo response, then converted to one later -// post-contrast magnitude. Test25's per-cone shoulder and Graph/Fast60 authoring -// are re-evaluated ONCE at that later state, after which the exact six-plane fit -// is applied again. Thus red follows the same authored red->white trajectory -// instead of a straight target-RGB lerp to white. At/below adaptation, chroma -// loss alone cannot create whiteward motion. -float3 psycho25_ApplyAdaptiveContrastFit( - float3 ideal_lms, - float3 desired_lms, - float3 direction_source_lms, - float3 current_adaptive_state_lms, - float3 anchor_in, - float3 anchor_out, - float3 target_lms_peak, - float contrast_power, - Psycho25ConeResponseParameters target_cone_response, - int hue_method, - float peak_value, - int target_gamut_mode, - int gamut_enforcement, - int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { - if (gamut_enforcement == PSYCHO25_GAMUT_ENFORCEMENT_NONE) { - return ideal_lms; - } - - float3 legal_lms = psycho25_ExactAdaptiveMBTargetFit( - ideal_lms, - current_adaptive_state_lms, - peak_value, - target_gamut_mode, - gamut_enforcement); - - float2 adapted_neutral_mb = - renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - float3 ideal_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - psycho25_ToAdaptiveRelativeWeightedLMS( - ideal_lms, - current_adaptive_state_lms)); - float3 legal_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - psycho25_ToAdaptiveRelativeWeightedLMS( - legal_lms, - current_adaptive_state_lms)); - float ideal_radius = length(ideal_mb.xy - adapted_neutral_mb); - float legal_radius = length(legal_mb.xy - adapted_neutral_mb); - float chroma_loss_fraction = saturate( - renodx::math::DivideSafe( - max(ideal_radius - legal_radius, 0.f), - ideal_radius, - 0.f)); - - float3 target_peak_lms = psycho25_LMSFromTargetRGB( - peak_value.xxx, - target_gamut_mode); - float legal_yf = max(psycho25_SignedYfFromLMS(legal_lms), 0.f); - float adapt_yf = psycho25_SignedYfFromLMS(current_adaptive_state_lms); - float target_peak_yf = max( - psycho25_SignedYfFromLMS(target_peak_lms), - adapt_yf + PSYCHO25_EPSILON); - float high_side_yf = saturate( - renodx::math::DivideSafe( - legal_yf - adapt_yf, - target_peak_yf - adapt_yf, - 0.f)); - float chroma_pressure = chroma_loss_fraction * high_side_yf; - - float ideal_achromatic = psycho25_AchromaticYfContrast( - ideal_lms, - current_adaptive_state_lms); - float legal_achromatic = psycho25_AchromaticYfContrast( - legal_lms, - current_adaptive_state_lms); - float peak_achromatic = psycho25_AchromaticYfContrast( - target_peak_lms, - current_adaptive_state_lms); - float lost_achromatic = max( - ideal_achromatic - legal_achromatic, - 0.f); - float achromatic_pressure = saturate( - renodx::math::DivideSafe( - lost_achromatic, - max(peak_achromatic - legal_achromatic, PSYCHO25_EPSILON), - 0.f)); - - // Smooth union of chromatic and achromatic pressure. Chromatic pressure is - // already Yf-weighted above, so saturated near-black colors do not advance. - float raw_pressure = 1.f - - (1.f - chroma_pressure) * (1.f - achromatic_pressure); - if (raw_pressure <= PSYCHO25_EPSILON) { - return legal_lms; - } - - float trajectory_pressure = raw_pressure - * rsqrt(1.f + raw_pressure * raw_pressure); - float trajectory_scale = rcp(max( - 1.f - trajectory_pressure, - PSYCHO25_EPSILON)); - - // desired_lms is already post-contrast. To keep the source state used by - // Graph/Fast60 consistent with that later contrast magnitude, invert the - // scalar contrast power for the pre-contrast direction source. - float safe_contrast_power = max( - contrast_power, - PSYCHO25_EPSILON); - float source_scale = pow( - trajectory_scale, - rcp(safe_contrast_power)); - - float3 advanced_ideal_lms = psycho25_ApplyPhysicalPerConePath( - desired_lms * trajectory_scale, - direction_source_lms * source_scale, - current_adaptive_state_lms, - anchor_in, - anchor_out, - target_lms_peak, - contrast_power, - target_cone_response, - hue_method, - observer_gamut_mode); - - return psycho25_ExactAdaptiveMBTargetFit( - advanced_ideal_lms, - current_adaptive_state_lms, - peak_value, - target_gamut_mode, - gamut_enforcement); -} - -float3 psycho25_CompressTargetHull( - float3 desired_lms, - float3 direction_source_lms, - float3 current_adaptive_state_lms, - float3 anchor_in, - float3 anchor_out, - float3 target_lms_peak, - float3 guidance_lms_peak, - float contrast_power, - float upper_plane_shoulder_power, - Psycho25ConeResponseParameters target_cone_response, - Psycho25ConeResponseParameters guidance_cone_response, - float peak_value, - int target_gamut_mode, - int gamut_enforcement, // independent lower/upper target-plane bitmask - int hue_method, - int hull_method, - int upper_hull_pivot, - float canonical_pressure_pivot, - float canonical_pressure_contrast, - float canonical_pressure_h, - float canonical_pressure_trade, - float canonical_yf_bias_power, - int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE) { - const bool enforce_gamut_primaries = (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0; - const bool enforce_gamut_peak = (gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0; - float3 desired_weighted_lms = - renodx::color::macleod_boynton::WeighLMS(desired_lms); - float desired_yf = desired_weighted_lms.x + desired_weighted_lms.y; - - // Every nonblack color satisfying the selected target's lower RGB planes has - // positive Yf. A nonpositive-Yf direction therefore intersects those planes - // only at the origin. Without primary enforcement, this signed stress case - // has no stable positive-Yf hull ray, so retain the direct physical path - // instead of implicitly imposing lower planes. - if (desired_yf <= PSYCHO25_EPSILON) { - if (enforce_gamut_primaries) { - return 0.f.xxx; - } - return psycho25_ApplyPhysicalPerConePath( - desired_lms, - direction_source_lms, - current_adaptive_state_lms, - anchor_in, - anchor_out, - target_lms_peak, - contrast_power, - target_cone_response, - hue_method, - observer_gamut_mode); - } - - float anchor_out_yf = psycho25_YfFromLMS(anchor_out); - float target_peak_yf = psycho25_SignedYfFromLMS(target_lms_peak); - - // Keep magnitude/radius tied to the real target peak, but derive the - // compressed hue direction from the target-relative neutral guidance - // endpoint. At the 1x default this is exactly the physical endpoint and - // per-channel response. Carried scale is discarded before upper-plane - // support. - float3 physical_compressed_lms = target_lms_peak - * psycho25_CompressionRolloffSignedPerCone( - desired_lms, - target_cone_response); - float3 guidance_direction_lms = - guidance_lms_peak - * psycho25_CompressionRolloffSignedPerCone( - desired_lms, - guidance_cone_response); - Psycho25AdaptiveMBTrajectory trajectory = - psycho25_BuildAdaptiveMBTrajectory( - physical_compressed_lms, - guidance_direction_lms, - direction_source_lms, - current_adaptive_state_lms, - anchor_in, - anchor_out, - guidance_lms_peak, - contrast_power, - guidance_cone_response, - hue_method, - observer_gamut_mode); - float3 safe_adaptive_state_lms = max( - current_adaptive_state_lms, - PSYCHO25_EPSILON.xxx); - float authored_yf = psycho25_YfFromLMS(physical_compressed_lms); - if (authored_yf <= PSYCHO25_EPSILON) { - if (enforce_gamut_primaries) { - return 0.f.xxx; - } - return psycho25_ApplyPhysicalPerConePath( - desired_lms, - direction_source_lms, - current_adaptive_state_lms, - anchor_in, - anchor_out, - target_lms_peak, - contrast_power, - target_cone_response, - hue_method, - observer_gamut_mode); - } - - float2 adapted_neutral_mb = - renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - float2 authored_offset = - trajectory.authored_mb.xy - adapted_neutral_mb; - float authored_radius2 = dot(authored_offset, authored_offset); - float authored_radius = sqrt(authored_radius2); - float2 authored_direction = authored_offset * rsqrt( - authored_radius2 + PSYCHO25_EPSILON * PSYCHO25_EPSILON); - float3 source_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - psycho25_ToAdaptiveRelativeWeightedLMS( - direction_source_lms, - current_adaptive_state_lms)); - - if ((hull_method == PSYCHO25_HULL_METHOD_REFERENCE_SCALE - || hull_method == PSYCHO25_HULL_METHOD_REDUCED_MAX_WHITE) - && enforce_gamut_primaries) { - // Independent cone shoulders eventually make every positive source - // approach LMS white. As that physical radius disappears, turn its - // direction continuously toward the pre-contrast source direction so - // saturated blue cannot rotate through an unrelated purple direction. - // Keep the physical radius itself unchanged so the result can continue - // through light blue to white. This is one smooth trajectory rather than - // a level- or hue-segmented correction. - float2 source_offset = source_mb.xy - adapted_neutral_mb; - float source_radius2 = dot(source_offset, source_offset); - float source_radius = sqrt(source_radius2); - float2 source_direction = source_offset * rsqrt( - source_radius2 + PSYCHO25_EPSILON * PSYCHO25_EPSILON); - float source_radius_support = - psycho25_TargetLowerPlaneRadiusForDirection( - source_direction, - adapted_neutral_mb, - current_adaptive_state_lms, - target_gamut_mode); - float source_direction_occupancy = - hull_method == PSYCHO25_HULL_METHOD_REFERENCE_SCALE - ? PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY - : PSYCHO25_REDUCED_MAX_WHITE_SOURCE_DIRECTION_OCCUPANCY; - float source_direction_support_radius = source_direction_occupancy - * source_radius_support - * renodx::math::DivideSafe( - source_radius, - sqrt( - source_radius2 - + source_radius_support * source_radius_support), - 0.f); - float radius_normalization = max( - max(authored_radius, source_direction_support_radius), - PSYCHO25_EPSILON); - float authored_weight = pow( - authored_radius / radius_normalization, - PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); - float source_direction_support_weight = pow( - source_direction_support_radius / radius_normalization, - PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); - float source_hue_support = - PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION * source_radius_support; - float source_hue_confidence = renodx::math::DivideSafe( - source_radius2, - source_radius2 + source_hue_support * source_hue_support, - 0.f); - float source_collapse_weight = renodx::math::DivideSafe( - source_direction_support_weight, - authored_weight + source_direction_support_weight, - 0.f); - float source_direction_weight = 1.f - - (1.f - source_hue_confidence) - * (1.f - source_collapse_weight); - float2 combined_direction = lerp( - authored_direction, - source_direction, - source_direction_weight); - combined_direction *= rsqrt( - dot(combined_direction, combined_direction) - + PSYCHO25_EPSILON * PSYCHO25_EPSILON); - authored_direction = combined_direction; - authored_offset = authored_direction * authored_radius; - trajectory.authored_mb.xy = adapted_neutral_mb + authored_offset; - } - - if (hull_method == PSYCHO25_HULL_METHOD_LINEAR_MB_PULLBACK - && enforce_gamut_primaries - && authored_radius > PSYCHO25_EPSILON) { - // Diagnostic path: retain the Graph/Fast60-authored adaptive-MB direction - // and actual-peak physical radius until the candidate crosses a selected- - // target lower plane, then pull that radius straight back to the first - // intersection. There is no reference radius, knee, neutral release, - // smooth support intersection, or source-direction recovery. - trajectory.authored_mb = psycho25_PullBackAdaptiveMBToTargetLowerPlanes( - trajectory.authored_mb, - adapted_neutral_mb, - current_adaptive_state_lms, - target_gamut_mode); - authored_offset = trajectory.authored_mb.xy - adapted_neutral_mb; - authored_radius = length(authored_offset); - } - - // Normalization removes the trajectory guide's carried scale. Only its - // adaptive-MB direction and radius survive into the legacy cube ray. The - // final direction is normalized only after source retention or linear - // pullback so the later physical-Yf scale cannot inherit a stale x - // coordinate. - float trajectory_yf_for_normalization = trajectory.authored_mb.z * ( - trajectory.authored_mb.x * safe_adaptive_state_lms.x - + (1.f - trajectory.authored_mb.x) * safe_adaptive_state_lms.y); - float3 unit_yf_lms = psycho25_LMSFromAdaptiveMB( - float3( - trajectory.authored_mb.xy, - renodx::math::DivideSafe( - trajectory.authored_mb.z, - trajectory_yf_for_normalization, - 0.f)), - current_adaptive_state_lms); - float3 neutral_lms = current_adaptive_state_lms - / psycho25_YfFromLMS(current_adaptive_state_lms); - - if (hull_method == PSYCHO25_HULL_METHOD_CANONICAL_CYLINDER) { - return psycho25_CompressCanonicalCylinderVolume( - unit_yf_lms * authored_yf, - current_adaptive_state_lms, - peak_value, - target_gamut_mode, - gamut_enforcement, - canonical_pressure_pivot, - canonical_pressure_contrast, - canonical_pressure_h, - canonical_pressure_trade); - } - - if (hull_method == PSYCHO25_HULL_METHOD_CANONICAL_YF_CONE) { - return psycho25_CompressCanonicalYfConeVolume( - unit_yf_lms * authored_yf, - current_adaptive_state_lms, - peak_value, - target_gamut_mode, - gamut_enforcement, - canonical_pressure_pivot, - canonical_pressure_contrast, - canonical_pressure_h, - canonical_yf_bias_power); - } - - if (hull_method == PSYCHO25_HULL_METHOD_SECTIONAL_WHITE_VOLUME) { - // The per-cone response supplies white convergence, the Graph/Fast60 - // trajectory supplies its curved 50% six-section hue direction, and this - // one cross-sectional map contracts only the same-Yf radial displacement. - // No fixed-source recovery, second tone curve, or wall-to-white post pass - // is applied afterward. - return psycho25_CompressSectionalWhiteVolume( - unit_yf_lms * authored_yf, - current_adaptive_state_lms, - peak_value, - target_gamut_mode, - gamut_enforcement); - } - - if (hull_method == PSYCHO25_HULL_METHOD_REFERENCE3 - && enforce_gamut_primaries - && enforce_gamut_peak) { - return psycho25_CompressTargetHueTriangleVolume( - unit_yf_lms * authored_yf, - peak_value, - target_gamut_mode); - } - - // The function returns a linear BT.709 representation even when the selected - // target hull is BT.2020. Negative BT.709 components are valid for colors - // outside BT.709 but inside BT.2020, so lower-plane feasibility must be - // evaluated in the selected target RGB space. Reference and Reduced Max- - // White solve against a same-authored hue reference no nearer the adaptive - // neutral than either the physical trajectory or its uncompressed post- - // contrast input. Reference2 leaves this same-Yf radial stage untouched; - // its lower-plane correction lifts the completed candidate toward D65 white. - if (enforce_gamut_primaries - && (hull_method == PSYCHO25_HULL_METHOD_REFERENCE_SCALE - || hull_method == PSYCHO25_HULL_METHOD_REDUCED_MAX_WHITE) - && authored_radius > PSYCHO25_EPSILON) { - float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( - neutral_lms, - target_gamut_mode); - - // Fixed-Yf LMS interpolation is not exactly adaptive-MB radial - // interpolation. Apply the smooth shoulder to the current ray as the final - // target-plane safeguard. - float3 current_target_rgb = psycho25_TargetRGBFromLMS( - unit_yf_lms, - target_gamut_mode); - float current_boundary_fraction = - psycho25_TargetLowerPlaneBoundaryFraction( - current_target_rgb, - neutral_target_rgb); - float current_radius_scale = - psycho25_CompressTargetLowerPlaneRadius( - current_boundary_fraction); - - authored_direction = authored_offset / authored_radius; - float containment_reference_radius = max( - authored_radius, - length(source_mb.xy - adapted_neutral_mb)); - float3 reference_lms = psycho25_LMSFromAdaptiveMB( - float3( - adapted_neutral_mb - + authored_direction * containment_reference_radius, - 1.f), - current_adaptive_state_lms); - reference_lms /= psycho25_YfFromLMS(reference_lms); - float3 reference_target_rgb = psycho25_TargetRGBFromLMS( - reference_lms, - target_gamut_mode); - - // Find the selected-target lower-plane boundary along the complete - // neutral-to-reference ray even while the reference remains in gamut. - // A boundary fraction above one means the current reference is inside. - float reference_boundary_fraction = - psycho25_TargetLowerPlaneBoundaryFraction( - reference_target_rgb, - neutral_target_rgb); - - // Compress a unit input ray with a rational shoulder whose value and - // first derivative both match identity at the knee. The output approaches - // the exact lower-plane boundary asymptotically rather than changing - // behavior when a target channel first crosses zero. - float reference_radius_scale = psycho25_CompressTargetLowerPlaneRadius( - reference_boundary_fraction); - - float trajectory_fraction = - authored_radius / containment_reference_radius; - float release_progress = saturate( - trajectory_fraction - / PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION); - float neutral_scale = min(1.f, 4.f * reference_radius_scale); - float release_weight = 1.f - release_progress; - float radius_scale = min( - lerp( - reference_radius_scale, - neutral_scale, - release_weight * release_weight), - current_radius_scale); - - unit_yf_lms = lerp(neutral_lms, unit_yf_lms, radius_scale); - } - - // With peak planes disabled, retain the no-gamut trajectory's authored Yf. - // Primary enforcement may still reduce adaptive-MB chrominance to fit the - // selected target's nonnegative RGB half-spaces. - if (!enforce_gamut_peak) { - float3 candidate_lms = unit_yf_lms * authored_yf; - if ((hull_method != PSYCHO25_HULL_METHOD_REFERENCE2 - && hull_method != PSYCHO25_HULL_METHOD_REFERENCE3) - || !enforce_gamut_primaries) { - return candidate_lms; - } - float3 candidate_target_rgb = psycho25_TargetRGBFromLMS( - candidate_lms, - target_gamut_mode); - float white_level = max( - peak_value, - max( - candidate_target_rgb.x, - max(candidate_target_rgb.y, candidate_target_rgb.z))); - return psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( - candidate_lms, - current_adaptive_state_lms, - white_level, - target_gamut_mode); - } - - float3 unit_target_rgb = psycho25_TargetRGBFromLMS( - unit_yf_lms, - target_gamut_mode); - - float max_target_channel = max( - unit_target_rgb.x, - max(unit_target_rgb.y, unit_target_rgb.z)); - float directional_yf_limit = peak_value / max_target_channel; - float shoulder_input_yf = enforce_gamut_primaries - ? desired_yf - : authored_yf; - - if (upper_hull_pivot == PSYCHO25_UPPER_HULL_PIVOT_ADAPTED_OUTPUT) { - // Experimental adapted-output pivot. Express the authored candidate as a - // displacement from the output/background anchor, measure how much of the - // available per-channel upper-plane headroom that displacement occupies, - // then apply the selected scalar shoulder power over the - // adapted-Yf-to-peak range. Scaling the complete LMS displacement keeps - // the anchor exact and avoids turning signed target RGB into a channel - // clamp. - float3 candidate_lms = unit_yf_lms * shoulder_input_yf; - float3 candidate_target_rgb = psycho25_TargetRGBFromLMS( - candidate_lms, - target_gamut_mode); - float3 anchor_target_rgb = psycho25_TargetRGBFromLMS( - anchor_out, - target_gamut_mode); - float3 target_headroom = peak_value.xxx - anchor_target_rgb; - float upper_occupancy = 0.f; - if (candidate_target_rgb.x > anchor_target_rgb.x) { - upper_occupancy = max( - upper_occupancy, - (candidate_target_rgb.x - anchor_target_rgb.x) - / target_headroom.x); - } - if (candidate_target_rgb.y > anchor_target_rgb.y) { - upper_occupancy = max( - upper_occupancy, - (candidate_target_rgb.y - anchor_target_rgb.y) - / target_headroom.y); - } - if (candidate_target_rgb.z > anchor_target_rgb.z) { - upper_occupancy = max( - upper_occupancy, - (candidate_target_rgb.z - anchor_target_rgb.z) - / target_headroom.z); - } - if (upper_occupancy <= PSYCHO25_EPSILON) { - return hull_method == PSYCHO25_HULL_METHOD_REFERENCE2 - && enforce_gamut_primaries - ? psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( - candidate_lms, - current_adaptive_state_lms, - peak_value, - target_gamut_mode) - : candidate_lms; - } - - float centered_input_yf = anchor_out_yf - + upper_occupancy - * (target_peak_yf - anchor_out_yf); - float centered_output_yf = psycho25_CompressionRolloffScalar( - centered_input_yf, - anchor_out_yf, - target_peak_yf, - upper_plane_shoulder_power); - float output_occupancy = (centered_output_yf - anchor_out_yf) - / (target_peak_yf - anchor_out_yf); - float displacement_scale = output_occupancy / upper_occupancy; - float3 output_lms = anchor_out - + (candidate_lms - anchor_out) * displacement_scale; - return hull_method == PSYCHO25_HULL_METHOD_REFERENCE2 - && enforce_gamut_primaries - ? psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( - output_lms, - current_adaptive_state_lms, - peak_value, - target_gamut_mode) - : output_lms; - } - - float normalized_input = shoulder_input_yf - * renodx::math::DivideSafe( - target_peak_yf, - directional_yf_limit, - 1.f); - float normalized_output = psycho25_CompressionRolloffScalar( - normalized_input, - anchor_out_yf, - target_peak_yf, - upper_plane_shoulder_power); - float output_yf = normalized_output - * renodx::math::DivideSafe( - directional_yf_limit, - target_peak_yf, - 1.f); - float3 output_lms = unit_yf_lms * output_yf; - return hull_method == PSYCHO25_HULL_METHOD_REFERENCE2 - && enforce_gamut_primaries - ? psycho25_LiftTargetRGBTowardWhitePreservingAdaptiveMBHue( - output_lms, - current_adaptive_state_lms, - peak_value, - target_gamut_mode) - : output_lms; -} - -// psychov-25 research source record and device-hull plan -// ------------------------------------------------------ -// -// Objective: -// PsychoV first targets the observer-side bend of the scene: -// - what state the eye adapts to, -// - how the scene is converted to contrast around that adapted state, -// - how the response is shaped around that adapted state, -// - which nonlinear curve applies at each stage. -// The human observer is not a linear gain system, so the observer model decides -// which scene differences remain important when the display hull forces -// compression. Tonemapping itself remains a device-hull problem, not an eye -// model. -// -// The design therefore distinguishes two coupled systems: -// - observer flow: a literature-backed receptor/adaptation/opponent roadmap; -// - device-hull mapping: a joint tone, hue, and gamut solve over the complete -// display hull. -// -// Current Test25 implementation status: -// - implemented: relative scene-linear BT.709 -> Stockman/CVRL LMS, -// weighted-LMS/Yf/adaptive-MB bookkeeping, caller-provided adaptation -// anchors, scalar-Yf grading, adaptive-MB purity, anchor-matched contrast, -// a retained no-gamut per-cone rolloff, a numerical 50% hue-graph solve, -// actual-trajectory selected-target -// lower-plane containment, a physical no-gamut trajectory guide, -// independently selectable target lower-plane and upper-plane support, and -// one scalar peak shoulder over the resulting device-hull ray when requested; -// - planned or not implemented: absolute retinal calibration, adaptation-state -// estimation, calibrated cone-noise thresholds, absolute photopigment -// bleaching, -// ACC/DKL response, -// explicit ON/OFF splitting, pooled cortical gain, equivalent-Gaussian hue, -// and a wider sectional optimization over multiple in-sextant hull points. -// -// Rahimi-Nasrabadi et al. (Cell Reports 2021, -// doi:10.1016/j.celrep.2021.108692) validated their ONOFF image algorithm on -// calibrated grayscale images and suggested applying it to color through the -// lightness dimension. Test25 therefore keeps highlight/shadow grading on -// scalar Yf rather than independently grading L, M, and S. This citation does -// not make the current per-cone display rolloff a biological ON/OFF model. -// -// Research basis and intended human-flow model: -// -// 1) Receptor basis — implemented as a relative rendering basis. -// Stockman-Sharpe LMS with CIE 170-2 physiological luminance Yf / weighted -// LMS bookkeeping, not CIE 1931 Y. -// -// Reference split: -// - Brainard, "Colorimetry" (chapter 10): the cone stage / color-match -// foundation. Chapter 11 explicitly points back to this chapter when it -// says, "The first stage of color vision is now well understood (see -// Chap. 10)." This supports scene RGB/XYZ -> cone excitations L, M, S. -// - Stockman & Brainard (chapter 11): builds on that receptor basis for -// first-site and second-site adaptation. -// Sources: -// https://color2.psych.upenn.edu/brainard/papers/Brainard_Stockman_Colorimetry.pdf -// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf -// -// CVRL notes that cone signals are formed only after prereceptoral filtering -// by ocular media and macular pigment. Both absorb mainly at short -// wavelengths and vary substantially across observers. The transform is an -// average-observer receptor basis unless those filters are modeled -// explicitly. -// References: CVRL background hub; "Macular and lens pigments": -// http://www.cvrl.org/background.htm -// http://www.cvrl.org/database/text/intros/intromaclens.htm -// -// MacLeod-Boynton is not itself the cortical flow. It is a weighted -// cone-chromaticity representation in an equal-luminance plane with a -// separately carried achromatic scale term. In implementation notation: -// l = Lw / (Lw + Mw) -// s = Sw / (Lw + Mw) -// y = Lw + Mw -// The fixed observer-transform coefficients form weighted LMS, the Yf-like -// achromatic response, and MB coordinates from LMS. They are not adaptation, -// gain, or bleaching terms. CVRL describes the CIE physiological functions -// as linear transforms of the Stockman & Sharpe cone fundamentals. Mantiuk -// et al. describe practical LMS scaling so that L+M corresponds to -// luminance. This is the mathematical role of the weights at this stage. -// -// Reference: MacLeod & Boynton (1979), -// doi:10.1364/JOSA.69.001183; modern CIE 170-2 implementations replace ad -// hoc weights with standardized physiological cone-fundamental/luminance -// weights. -// -// Citation split for the coefficients used by the RenoDX transform: -// - explicit CIE 170-2 / physiological-weight usage: CIE/CVRL -// physiological functions, Psychtoolbox LMSToMacBoyn, and the repository -// Stockman/MacLeod-Boynton shader wiring; -// - classic or modified MB without an explicit CIE 170-2 coefficient claim: -// MacLeod & Boynton (1979), Webster & Leonard (2008); -// - LMS scaled so the achromatic term is L+M, without an explicit CIE 170-2 -// MB coefficient claim: Mantiuk et al. (2020). -// Classic MB, modified MB, and plain L+M-scaled LMS must not be cited as if -// they automatically justify the exact CIE 170-2 coefficients used here. -// Sources: -// http://www.cvrl.org/ciexyzpr.htm -// https://psychtoolbox.org/docs/LMSToMacBoyn -// https://pmc.ncbi.nlm.nih.gov/articles/PMC2657039/ -// https://www.cl.cam.ac.uk/~rkm38/pdfs/mantiuk2020practical_csf.pdf -// -// 2) Early cone adaptation — caller-provided anchors are implemented; -// adaptation estimation and a fitted physiological response are not. -// Maintain an adapting background state (L0, M0, S0, Yf0), then express the -// stimulus relative to that background before a postreceptoral transform. -// Chapter 10 gives absolute cone excitations; chapter 11 defines how they -// depend on the adapting background and become a contrast representation. -// -// Source-backed first-site math is cone-specific contrast/gain control, not -// a rule that every adapted background maps to one fixed output level. -// Stockman & Brainard write first-site L-cone contrast as: -// C_L = delta_L / (L_b + L_0) -// with analogous forms for M and S. Equivalently: -// g_L = 1 / (L_b + L_0) -// g_L * (L - L_b) = delta_L / (L_b + L_0) -// Thus the observer approximately normalizes cone signals by the adapted -// background. First-site adaptation is neither complete nor instantaneous; -// later second-site adaptation further reshapes postreceptoral signals. -// References: Stockman & Brainard (2010); Stockman et al. (JOV 2006, -// doi:10.1167/6.11.5). -// -// Webster & Leonard (2008) distinguish their "response norm," the adapting -// level that does not bias white judgments, from their "perceptual norm," the -// stimulus that appears white. Those norms tracked closely in their -// experiments, but neither is the same term as Stockman & Brainard's -// background cone excitations or Mantiuk et al.'s background responses. The -// directly modeled early state is best called the adapted background -// reference; response/perceptual norms are higher-level interpretations of -// why that reference acts as the current neutral coding state. -// Source: https://pmc.ncbi.nlm.nih.gov/articles/PMC2657039/ -// -// CVRL further notes that luminosity functions depend strongly on chromatic -// adaptation and observing conditions, whereas cone spectral sensitivities -// remain fixed until photopigment bleaching becomes significant. This is why -// Yf bookkeeping remains tied to the adapted observer state rather than a -// condition-invariant photometric curve. -// Reference: CVRL "Luminosity functions": -// http://www.cvrl.org/database/text/intros/introvl.htm -// -// 2a) Dim cone-noise regime — research plan, not implemented. -// Before rod-dominated vision, cone-mediated detection can already be -// limited by quantal/transduction noise. In this dim-but-still-cone regime, -// threshold cone contrast follows approximately De Vries-Rose behavior: in -// log-log space, threshold contrast falls with retinal illuminance at slope -// near -0.5. At higher levels the system approaches Weber-like behavior, -// where threshold contrast is roughly constant relative to the background. -// Weak scene differences may therefore disappear into a cone-noise-limited -// floor before rod vision dominates. -// Reference direction: -// - Stockman & Brainard (2010): cone-contrast space is most useful when -// first-site adaptation is in the Weber regime and less useful where -// adaptation falls short of Weber's law; -// - Angueyra & Rieke (2013): primate cone photoreceptors exhibit measurable -// phototransduction noise. -// Sources: -// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf -// https://pmc.ncbi.nlm.nih.gov/articles/PMC3815624/ -// 2b) High-light bleaching — research plan, not implemented. -// At sufficiently high retinal illuminance, a Rushton-Henry-style law in -// trolands describes per-cone pigment availability: -// p_available(I) = 1 / (1 + I / I0) -// This complements the commonly cited fraction-bleached law: -// p_bleached(I) = I / (I + I0) -// with I0 approximately 10^4.3 Td for cones. -// -// A rendering interpretation can apply availability to cone excursions -// around an adapted-white anchor so availability -> 0 approaches equal -// white at the carried achromatic level. That interpretation must not be -// confused with the current per-cone display rolloff. -// Sources and attribution: -// - Stockman et al. (JOV 2006, doi:10.1167/6.11.5): high-light sensitivity -// regulation is maintained mainly by photopigment bleaching; -// - Stockman et al. (JOV 2018, 18(6):12): appendix gives -// p = I / (I + I0), I0 = 10^4.3 Td, citing Rushton & Henry (1968); -// - CVRL "Bleaching": -// http://www.cvrl.org/database/text/intros/introbleaches.htm -// Physiological bleaching still belongs after the adapted background is -// defined and before postreceptoral opponent encoding, pooled gain, and -// device-hull mapping. -// -// 3) Background-normalized opponent drive — research plan beyond adaptive MB. -// Convert cone-domain responses into ACC/DKL-style opponent coordinates -// using a background-referenced weighted-LMS achromatic axis. MacLeod- -// Boynton describes chromaticity on an equal-luminance plane, whereas ACC / -// DKL are opponent combinations of cone increments around a background. MB -// therefore carries hue/device geometry and achromatic Yf bookkeeping here; -// ACC/DKL remains the planned space for postreceptoral response and gain. -// -// 4) Saturating contrast response — current rolloff is an engineering curve. -// A future receptor/early-cortical stage may use a Michaelis-Menten or -// Naka-Rushton-like nonlinearity. Some cortical fits may need a -// supersaturating variant. -// Reference: Peirce (JOV 2007, doi:10.1167/7.6.13). -// -// 5) ON/OFF separation — research constraint, not an explicit Test25 split. -// Split increments and decrements around the adapted/background state with -// half-wave rectification before pooled gain. The split is around -// adaptation, not diffuse white. Modern retina work also shows that ON/OFF -// nonlinearities can cancel in natural images, producing a more linear -// effective response than a single static saturating curve suggests. ON/OFF -// therefore constrains the neutral and OFF-side slope; it does not require a -// hard branch in the default curve. -// References: Schiller (1992); Yu, Turner, Baudin & Rieke, -// eLife 2022, 11:e70611, doi:10.7554/eLife.70611. -// -// 6) Pooled cortical gain — research plan, not implemented. -// A full observer stage still requires background-referenced opponency, -// ON/OFF separation, and fitted divisive gain parameters. -// References: Heeger (1992); Carandini & Heeger (2012); Bun & Horwitz -// (2023); Li et al. (2022). -// -// 7) Unified device-hull tonemapping and gamut mapping — active design plan. -// Map the observer-domain result into the display hull while retaining the -// most plausible achromatic and opponent contrast structure the device can -// represent. Diffuse/reference white, adapted neutral, and display peak are -// distinct anchors. ITU-R BT.2408's HDR Reference White framing is the -// practical video reference for keeping diffuse white below specular/display -// peak. -// -// Full normalized BT.709 hull: -// - peak 1.0 and BT.709 constraints together define 0 <= R,G,B <= 1; -// - this is one RGB cube, not a per-channel-to-white operation followed by a -// separate gamut constraint; -// - Test25 runtime units generalize the upper planes to `peak_value`, so the -// equivalent hull is 0 <= R,G,B <= peak_value in the selected target RGB -// basis; -// - the primary triangle is only the chromaticity-plane projection of part -// of this geometry. It does not describe upper faces or complete -// constant-scale cross-sections of the cube; -// - lower and upper channel faces, cube edges/corners, and relevant LMS -// bounds must be considered inside each cone-axis sextant; -// - BT.709 is the primary normalized design target. BT.2020 is a generalized -// target-mode extension, not a reason to weaken the BT.709 formulation. -// -// Sextant constraint: -// - isolated L/M/S displacement axes and their antipodes establish the six -// sections independently of any white rolloff or RGB target; -// - per-cone compression may supply one candidate interior hue objective, -// but it is not required to discover the sections and is not the hull; -// - the final solve must examine the complete target cross-section within -// the active sextant and LMS bounds, rather than assuming radial motion to -// adapted neutral is always optimal. -// -// Device-hull inference: -// - many display hulls can produce more total achromatic output by combining -// primaries than at the same level with a high-purity excursion; -// - an out-of-hull observer response may therefore trade chromatic shape -// toward the achromatic axis when the complete hull demands it; -// - the preferred result is not blind clipping to white, but the face, edge, -// corner, or interior point that best preserves observer-domain contrast -// structure; -// - white is one valid destination when bleaching or an achromatic optimum -// dominates, not the mandatory destination of gamut compression. -// -// Engineering direction inferred from the sources above: -// - use weighted LMS / MB to carry achromatic Yf and cone-axis geometry; -// - use an opponent representation to judge postreceptoral contrast; -// - construct and solve the full display hull in that combined state rather -// than first collapsing channels toward white and then clipping in RGB. -// -// Coupling constraint: -// - hue, tone, and device-hull compression are not independent steps; -// - a hue change after hull compression can push the result out of hull; -// - hue-preserving motion must be solved inside the hull projection or be -// followed by explicit in-hull reprojection; -// - the current complete-cube ray support proves containment with one scalar -// shoulder, but it is a partial implementation of the full sectional -// optimization rather than proof that its one authored direction is the -// globally preferred observer-domain trade. -// Reference direction: MacLeod-Boynton/CIE 170-2 geometry, repository -// weighted-LMS/MB transforms, and the device-hull notes above. -// -// 7a) Optional hue objective inside the device-hull solve — research plan. -// If display compression bends hue incorrectly, the solve may preserve an -// "equivalent Gaussian peak" proxy rather than a raw opponent angle. At -// short and medium wavelengths, perceived hue can behave more like a -// constant spectral peak of an equivalent Gaussian than a constant cone -// ratio as purity changes. -// Practical form: -// - offline, map weighted-LMS/MB chromaticities to an equivalent-Gaussian -// peak parameter mu_eq using a spectral forward model; -// - online, preserve mu_eq inside device-hull mapping while carrying Yf -// separately; -// - do not apply an unconstrained post-hoc hue shift after containment. -// This is an optional hull objective, not a chronological eye stage. -// References: Mizokami et al. (JOV 2006, doi:10.1167/6.9.12); -// O'Neil et al. (JOSAA 2012, doi:10.1364/JOSAA.29.00A165). -// -// 7b) Smooth auto-compression heuristic — currently implemented per cone. -// `compression == 0` derives h from the simultaneous-range reference above: -// one side around adaptation = reference_range_log10 / 2 -// h = (reference_range_log10 / 2) / log10(peak / anchor_out) -// pow(anchor_out / peak, h) = pow(10, -(reference_range_log10 / 2)) -// S_shadow = contrast / (1 - pow(anchor_out / peak, h)) -// The OFF/shadow slope error is derived from the selected reference range. -// Manual positive compression values remain exact. References: Kunkel & -// Reinhard, APGV 2010, doi:10.1145/1836248.1836251; Jiang & Fairchild, -// JIST 2021, doi:10.2352/J.ImagingSci.Technol.2021.65.5.050401. -// -// Current Test25 implementation map: -// ```mermaid -// flowchart LR -// rgb["Scene-linear BT.709"] --> lms["Stockman/CVRL LMS"] -// lms --> grade["Scalar-Yf highlights/shadows"] -// grade --> purity["Adaptive-MB purity"] -// purity --> contrast["Anchor-matched per-cone contrast"] -// contrast --> branch{"Gamut compression enabled?"} -// branch -->|No| rolloff["Retained per-cone LMS shoulder"] -// rolloff --> fallback["Numerical hue-graph solve"] -// branch -->|Yes| authored["Graph-solved trajectory direction"] -// authored --> direction["Continuous source-direction recovery"] -// direction --> planes["Physical radius + selected target planes"] -// planes --> scalar["One scalar shoulder over directional Yf support"] -// fallback --> output["BT.709-linear result"] -// scalar --> output -// ``` -// -// Research roadmap and source-state map: -// ```mermaid -// flowchart TB -// subgraph inputs["Raw inputs / assumptions"] -// rgb2["Scene-linear RGB"] -// colorimetry["Input RGB basis / white / RGB-to-LMS"] -// absolute["Absolute scene scale / retinal context"] -// background["Adaptation drivers / local background"] -// scene_range["Late image context / range"] -// observer["Stockman/CVRL observer assumptions"] -// display["Display primaries / white / peak / black / full hull"] -// end -// subgraph observer_flow["Observer roadmap"] -// receptor["Receptor LMS"] -// adapt["Adapted background reference"] -// cone_contrast["Per-cone background-relative response"] -// bleaching["Bleaching availability"] -// noise["Dim cone-noise visibility floor"] -// opponent["Opponent / achromatic response"] -// onoff["ON / OFF response"] -// gain["Pooled divisive normalization"] -// observer_out["Observer-domain response"] -// end -// subgraph device_map["Joint device-hull mapping"] -// hue_objective["Hue objective: MB / ACC / mu_eq"] -// sextants["Cone-axis sextants + LMS bounds"] -// cube["Full target RGB cube cross-sections"] -// hull_solve["Joint tone / hue / gamut solve"] -// hull_output["Display-hull output"] -// end -// rgb2 --> receptor -// colorimetry --> receptor -// observer --> receptor -// absolute --> receptor -// receptor --> adapt -// background --> adapt -// receptor --> cone_contrast -// adapt --> cone_contrast -// cone_contrast --> bleaching --> noise --> opponent --> onoff --> gain -// scene_range --> gain -// gain --> observer_out -// observer_out --> hue_objective -// observer_out --> hull_solve -// hue_objective --> hull_solve -// sextants --> hull_solve -// display --> cube --> hull_solve --> hull_output -// ``` -// -// Implementation scope: -// - The caller supplies the adapted source state and desired output background -// state. Neutral defaults are 0.18/0.18, so ordinary non-adapting content is -// not moved by the anchors. -// - The receptor basis is an average-observer, mainly foveal Stockman/CVRL -// basis with standard prereceptoral filtering folded into its functions. It -// is not a personalized observer model. -// - Scalar defaults are normalized rendering controls, not fitted -// physiological constants. -// - Conceptually, observer response and device mapping remain distinct. The -// current `psycho25_CompressTargetHull` combines authored hue, selected -// target-plane support, and scalar compression because they must remain -// coupled in practice. -// - Reference and Reduced Max-White derive a bounded direction-support scale -// from the pre-contrast source as independent cone shoulders approach LMS -// white. For source radius r_s and selected-target lower-plane support R_s: -// q_s = rho R_s r_s / sqrt(r_s^2 + R_s^2), -// with rho = 0.8 for Reference and 1 for Reduced Max-White. A quadratic -// collapse weight turns direction continuously toward the source as the -// physical authored radius vanishes. The output radius remains the physical -// radius, so chromatic highlights can still converge on white. The ordinary -// target solve supplies lower-plane correction and max-channel upper-plane -// support. No hue-sector branch, source gamut, output channel clamp, active -// limiting-face branch, retained radius, or segmented Yf range is introduced. - -// Public API contract: -// - `bt709_linear_input` is always scene/display-linear BT.709 RGB. Target -// gamut mode does not change this input conversion. -// - The return value is also represented as linear BT.709 RGB. A BT.2020 -// target may require negative BT.709 components; callers must convert to the -// target RGB space before applying target-space channel limits. -// - `peak_value` is the upper RGB-channel plane in units relative to the -// caller's reference white. A 100-nit peak / 100-nit reference-white test -// therefore uses 1. Runtime target containment is -// 0 <= target RGB <= peak_value. The caller must provide a positive peak -// whose D65 LMS and Yf values are strictly above the output/background -// anchor; invalid display configurations are not clamped or repaired. -// - `gamut_compression_mode`: 0 = BT.709 target, 1 = BT.2020 target. -// - Solved hue evaluation always carries the measured adaptive-MB radius. -// No source gamut is declared, inferred, or used as a normalization bound. -// - Hue authoring defaults to the numerical graph solve. `hue_method` selects -// the Fast60 comparison path, which uses the normalized 50% adaptive-MB -// midpoint and skips peak search plus inverse graph solving. -// - `hull_method` defaults to the reference-scale path, whose source-direction -// recovery uses 80% of its bounded target-relative support as the physical -// radius approaches white. Reduced Max-White raises that direction-support -// factor to 100%; neither mode retains a radius floor. Linear MB Pullback -// instead preserves the -// authored adaptive-MB direction and pulls its radius straight back to the -// first selected-target lower plane, with no lower-plane shoulder or custom -// radius construction. Target RGB Clip bypasses target-hull mapping and -// directly clamps the result in the selected linear BT.709 or BT.2020 RGB -// cube. -// - `hull_method == PSYCHO25_HULL_METHOD_CANONICAL_CYLINDER` selects the -// experimental canonical-cylinder map. It treats the authored adaptive-MB -// trajectory as the preferred point, computes exact selected-target radial -// support at its hue/Yf, forms q = rho/rho_max, leaves q <= 1 unchanged, and -// redirects q > 1 both inward and upward toward target peak D65 white. The -// four `canonical_pressure_*` controls match the interactive experiment: -// pivot = excess-occupancy scale, contrast = pressure exponent, h = bounded -// generalized-Neutwo shoulder, trade = 0 inward-first to 1 upward-first. -// The experiment is defined only for full lower+upper cube enforcement; -// partial plane modes remain on their existing diagnostic paths. -// - `post_compression_mode` selects an independent experiment. Modes Direct -// through Source MB Soft branch from the common post-contrast LMS signal and -// bypass physical per-cone output compression, Graph/Fast60 hue authoring, -// and coupled target-hull mapping. Direct applies no device constraint. -// Per-Channel and Max-Channel apply one -// selected-target RGB shoulder. Adaptive MB Hard, Adaptive MB Soft, and -// Fixed D65 Soft first apply their named lower-plane mapper and then the -// max-channel shoulder. Source MB variants first restore the pre-contrast -// adaptive-MB direction while retaining post-contrast radius and carried -// coordinate. Source BT709 Residual retains the normal coupled Reference -// result's relative luminance and linear-BT.709 residual magnitude, replaces -// only that residual direction with the source direction, and shortens it -// uniformly when selected-target containment requires it. The compatibility -// default is None. -// PsychoV17 Gamut + Neutwo Max retains the physical/hue direction but derives -// scalar magnitude from the common unbounded post-contrast signal after the -// same primary map. One anchor-normalized Neutwo shoulder is its peak map. -// - `PSYCHO25_POST_COMPRESSION_ADAPTIVE_CONTRAST_FIT` keeps Test25's completed -// physical/MIDPOINT result as the ideal point, fits it to the exact enabled -// selected-target six-plane support at the same adaptive-MB hue/Yf, then -// measures only bounded adaptation-relative Yf contrast lost by that fit. -// Lost chromatic radius is weighted by position above adapted Yf, genuine -// lost achromatic Yf is added independently, and their bounded pressure -// advances one later Test25 per-cone/MIDPOINT state before exact refitting. -// No straight RGB-to-white interpolation is used; near black chroma loss -// alone produces no trajectory advance. -// - `upper_hull_pivot` defaults to the existing black-origin constant-ratio -// peak ray. The experimental adapted-output mode instead applies the peak -// shoulder to target-channel headroom measured from `anchor_out`, keeping -// that adapted output/background state as the exact geometric pivot. -// - `compression`: positive = manual shoulder h; 0 = automatic h derived from -// the centered simultaneous-range reference. Manual h parameterizes both -// the no-gamut per-cone fallback and target-plane trajectory guide. Automatic -// h is resolved against each path's respective peak. Whenever any target -// plane is enabled, the direction guide uses a neutral endpoint of -// `target_peak_yf * guidance_peak_scale`; the real target peak remains -// unchanged for physical magnitude, radius, and upper-plane containment. -// - `guidance_peak_scale`: target-relative neutral Yf endpoint multiplier for -// target-plane hue guidance. It defaults to 1, is clamped to at least 1, -// and is ignored when no target planes are active. At 1x the guide is the -// regular physical per-channel shoulder. -// - `upper_plane_shoulder_power`: positive = independent upper-plane scalar -// shoulder h; 0 = match the resolved `compression` h. It has no effect when -// target peak/upper-plane enforcement is disabled. -// - `gamut_compression`: <= epsilon selects the retained per-cone LMS fallback; -// > epsilon selects both target-plane classes under legacy enforcement. -// Intermediate strength values are intentionally not a blend between two -// compressors. -// - `gamut_enforcement` independently selects target primary/lower-plane and -// target peak/upper-plane enforcement. With peak enforcement disabled, the -// gamut branch retains the authored Yf instead of imposing an RGB-channel -// peak. The legacy default follows `gamut_compression`: disabled maps to no -// target planes and enabled maps to both plane classes. -// - `cone_response_exponent` remains the response multiplier over the direct -// adapted-LMS contrast and purity controls. `encoded_response_power` is an -// adapted-anchor-preserving power in the compression-encoded response -// domain. -// - `input_pre_step` optionally retains signed LMS, clamps to positive LMS, -// clips to CIE 170-2, or aligns the signed MB hue ray to CIE 170-2 while -// retaining absolute Yf. -// - `observer_gamut_mode` is independent of `input_pre_step` and selected -// target gamut. CIE 170-2 mode constrains actual LMS immediately after -// per-cone contrast, before the physical/guidance shoulders and hue graph. -// It projects to the exact CIE 170-2 MacLeod-Boynton boundary along the -// fixed D65-relative hue ray while carrying nonnegative weighted L+M. The -// graph applies the same constraint to each candidate contrast response. -// None is the compatibility default. -// - `clip_point`, `hue_restore`, `white_curve_mode`, `adaptive_normalization`, -// `bleaching_intensity`, `highlight_saturation`, and `gamut_hue_restore` -// are retained for source compatibility but ignored. -float3 psychotm_test25( - float3 bt709_linear_input, // linear BT.709 RGB - float peak_value = 1000.f / 203.f, // target RGB upper plane - float exposure = 1.f, // linear scaling - float highlights = 1.f, // scalar-Yf highlight grade - float shadows = 1.f, // scalar-Yf shadow grade - float contrast = 1.f, // anchor-matched contrast - float purity_scale = 1.f, // adaptive-MB purity/contrast - float bleaching_intensity = 1.f, // ignored - float clip_point = 100.f, // ignored - float hue_restore = 1.f, // ignored - float encoded_response_power = 1.f, // encoded-domain power - int white_curve_mode = 0, // ignored - float cone_response_exponent = 1.f, // contrast/purity response - float3 current_adaptive_state_bt709 = 0.18f, // input/adaptation anchor - float3 current_background_state_bt709 = 0.18f, // output/background anchor - float gamut_compression = 1.f, // 0 per-cone; >0 legacy full hull - int gamut_compression_mode = 1, // target: BT.709/BT.2020 - float adaptive_normalization = 1.f, // ignored - float compression = 0.f, // shoulder h; 0 = auto - float highlight_saturation = 1.f, // ignored - float gamut_hue_restore = 0.f, // ignored - int hue_method = PSYCHO25_HUE_METHOD_GRAPH, - int hull_method = PSYCHO25_HULL_METHOD_REFERENCE_SCALE, - int gamut_enforcement = PSYCHO25_GAMUT_ENFORCEMENT_LEGACY, - int upper_hull_pivot = PSYCHO25_UPPER_HULL_PIVOT_BLACK, - float upper_plane_shoulder_power = PSYCHO25_UPPER_PLANE_SHOULDER_POWER_MATCH_COMPRESSION, - float guidance_peak_scale = PSYCHO25_DEFAULT_GUIDANCE_PEAK_SCALE, - int input_pre_step = PSYCHO25_INPUT_PRESTEP_NONE, - int observer_gamut_mode = PSYCHO25_OBSERVER_GAMUT_NONE, - int post_compression_mode = PSYCHO25_POST_COMPRESSION_NONE, - float canonical_pressure_pivot = PSYCHO25_CANONICAL_CYLINDER_DEFAULT_PIVOT, - float canonical_pressure_contrast = PSYCHO25_CANONICAL_CYLINDER_DEFAULT_CONTRAST, - float canonical_pressure_h = PSYCHO25_CANONICAL_CYLINDER_DEFAULT_H, - float canonical_pressure_trade = PSYCHO25_CANONICAL_CYLINDER_DEFAULT_TRADE, - float canonical_yf_bias_power = PSYCHO25_CANONICAL_YF_CONE_DEFAULT_BIAS_POWER) { - float response_scale = cone_response_exponent; - contrast *= response_scale; - purity_scale *= response_scale; - float safe_encoded_response_power = encoded_response_power; - - // The synthetic EXR stress chart contains binary16 infinities. Letting those - // enter the LMS matrices creates NaNs, which bypass gamut/peak comparisons - // and are later displayed at the presenter's safety clamp. Preserve their - // signs at the largest finite binary16 value; map undefined NaNs to black. - float3 exposed_bt709 = bt709_linear_input * exposure; - float3 finite_bt709_input = renodx::math::ZeroNaN(exposed_bt709); - finite_bt709_input = renodx::math::Select( - isinf(finite_bt709_input), - renodx::math::CopySign(65504.f.xxx, finite_bt709_input), - finite_bt709_input); - float3 lms_in = - renodx::color::lms::from::BT709(finite_bt709_input); - lms_in = psycho25_ApplyInputPreStep(lms_in, input_pre_step); - float3 target_lms_peak = - renodx::color::lms::from::BT709(float(peak_value).xxx); - float3 current_adaptive_state_lms = - renodx::color::lms::from::BT709(current_adaptive_state_bt709); - float3 desired_background_state_lms = - renodx::color::lms::from::BT709(current_background_state_bt709); - - // ------------------------------------------------------------------------- - // Anchor-matched adapted-D65 response. - // input == anchor_in maps to anchor_out for any compression setting. - // Test25 accepts these states from the caller; it does not estimate retinal - // adaptation or bleaching internally. - // ------------------------------------------------------------------------- - float3 anchor_in = current_adaptive_state_lms; - float3 anchor_out = desired_background_state_lms; - float contrast_power = contrast; - - // ------------------------------------------------------------------------- - // Achromatic highlight/shadow controls. - // The ONOFF source is luminance-only. Evaluating the grading curves once on - // Yf and applying a scalar gain to the complete LMS vector avoids an - // unsupported independent L/M/S grade and its resulting hue rotation. - // Cone signs are retained through authored hue and target containment. - // ------------------------------------------------------------------------- - float3 graded_lms = abs(lms_in); - float graded_yf = psycho25_YfFromLMS(graded_lms); - float adapted_anchor_yf = psycho25_YfFromLMS(anchor_in); - float graded_yf_out = psycho25_HighlightsScalarV4( - graded_yf, - highlights, - adapted_anchor_yf); - graded_yf_out = psycho25_ShadowsScalarV4( - graded_yf_out, - shadows, - adapted_anchor_yf); - graded_lms *= renodx::math::DivideSafe( - graded_yf_out, - graded_yf, - 1.f); - graded_lms = renodx::math::CopySign(graded_lms, lms_in); - - // ------------------------------------------------------------------------- - // Purity delta in adaptive MB: - // purity_delta = purity / contrast - // contrast == purity: no purity change. - // purity > contrast: increase radius from adapted neutral. - // purity < contrast: reduce radius toward adapted neutral. - // ------------------------------------------------------------------------- - float purity_delta = renodx::math::DivideSafe(purity_scale, contrast_power, 1.f); - float3 contrast_input = psycho25_ApplyAdaptiveMBPurity( - graded_lms, - anchor_in, - purity_delta); - - // ------------------------------------------------------------------------- - // Anchor-matched contrast remains explicit before display compression so - // source adaptive-MB direction/radius and the current rolloff-derived hue - // field can be evaluated separately. The optional observer-gamut stage is - // applied here, after contrast rather than as an input pre-step, and to the - // corresponding post-contrast state of every numerical hue-graph candidate. - // ------------------------------------------------------------------------- - float3 contrast_lms = psycho25_ApplyContrastResponse( - contrast_input, - anchor_in, - anchor_out, - contrast_power, - observer_gamut_mode); - - // ------------------------------------------------------------------------- - // Display-compression shoulder parameter. - // Positive `compression` is manual h; zero selects the centered-range auto - // value. The helpers implement the slope-normalized formula documented - // above. This resolved h parameterizes the no-gamut per-cone fallback and - // the real-peak magnitude in target-plane mode. An automatic direction guide - // resolves h again against its target-relative guidance endpoint; a positive - // manual h remains shared. The upper-plane scalar shoulder matches the real-peak h by - // default but can use its own positive h for diagnosis. - // Its slope-normalized power first encodes an adapted cone-response state. - // Sign-preserving encoded-response power is applied in that domain before - // the rational shoulder generates the channel scale. Hue authoring carries - // the measured adaptive-MB radius in both comparison modes. - // ------------------------------------------------------------------------- - float target_compression_power = compression; - if (compression == PSYCHO25_AUTO_COMPRESSION_SENTINEL) { - target_compression_power = - psycho25_AutoCompressionFromCenteredReferenceRange( - psycho25_YfFromLMS(anchor_out), - psycho25_YfFromLMS(target_lms_peak)); - } - target_compression_power = max( - target_compression_power, - PSYCHO25_MIN_MANUAL_COMPRESSION); - float resolved_upper_plane_shoulder_power = upper_plane_shoulder_power; - if (upper_plane_shoulder_power - == PSYCHO25_UPPER_PLANE_SHOULDER_POWER_MATCH_COMPRESSION) { - resolved_upper_plane_shoulder_power = target_compression_power; - } - resolved_upper_plane_shoulder_power = max( - resolved_upper_plane_shoulder_power, - PSYCHO25_MIN_MANUAL_COMPRESSION); - - // ------------------------------------------------------------------------- - // Coupled authored-hue and device-hull stage. With any target plane active, - // the per-cone guide uses a target-relative neutral Yf endpoint. At the 1x - // default this is the same endpoint as regular per-channel compression. The - // configured target peak remains the - // actual upper cube plane. Numerical mode solves the hue graph; Fast60 uses - // its direct angular midpoint with the source. Both retain the actual-peak - // physical radius and discard carried scale before target support is solved. - // Enabled target lower planes constrain adaptive-MB chrominance. Enabled - // upper planes define one directional Yf limit, and one scalar shoulder maps - // into it. This remains a single hull-ray solve, not the planned sectional - // optimization over multiple candidate points. - // ------------------------------------------------------------------------- - int normalized_target_gamut_mode = gamut_compression_mode == 0 ? 0 : 1; - const bool use_psychov17_gamut = post_compression_mode - == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT - || post_compression_mode - == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NEUTWO_MAX - || post_compression_mode - == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NRG_WHITE; - const bool use_psychov17_neutwo_peak = post_compression_mode - == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NEUTWO_MAX; - const bool use_psychov17_nrg_white = post_compression_mode - == PSYCHO25_POST_COMPRESSION_PSYCHOV17_GAMUT_NRG_WHITE; - const bool use_adaptive_contrast_fit = post_compression_mode - == PSYCHO25_POST_COMPRESSION_ADAPTIVE_CONTRAST_FIT; - int resolved_gamut_enforcement = gamut_enforcement < 0 - ? (gamut_compression <= PSYCHO25_EPSILON - ? PSYCHO25_GAMUT_ENFORCEMENT_NONE - : PSYCHO25_GAMUT_ENFORCEMENT_FULL) - : gamut_enforcement & PSYCHO25_GAMUT_ENFORCEMENT_FULL; - Psycho25ConeResponseParameters target_cone_response = - psycho25_PrepareConeResponseParameters( - anchor_out, - target_lms_peak, - contrast_power, - target_compression_power, - safe_encoded_response_power); - float3 signed_direction_source_lms = contrast_input; - float3 output_lms; - if (post_compression_mode >= PSYCHO25_POST_COMPRESSION_DIRECT - && post_compression_mode <= PSYCHO25_POST_COMPRESSION_SOURCE_MB_SOFT_MAX - && resolved_gamut_enforcement != PSYCHO25_GAMUT_ENFORCEMENT_NONE) { - // Post experiments deliberately branch before every physical per-cone, - // Graph/Fast60, and coupled-hull output operation. The optional observer - // constraint remains an independent earlier stage through contrast_lms. - output_lms = psycho25_ApplyIndependentPostCompression( - contrast_lms, - signed_direction_source_lms, - anchor_out, - current_adaptive_state_lms, - peak_value, - target_compression_power, - normalized_target_gamut_mode, - resolved_gamut_enforcement, - post_compression_mode); - } else if (resolved_gamut_enforcement - == PSYCHO25_GAMUT_ENFORCEMENT_NONE - || use_psychov17_gamut - || use_adaptive_contrast_fit) { - // No-gamut mode retains the direct per-channel LMS compressor. The - // PsychoV17 option deliberately starts from this same complete Test25 - // physical/hue result before its separate final primary-gamut map. - output_lms = psycho25_ApplyPhysicalPerConePath( - contrast_lms, - signed_direction_source_lms, - current_adaptive_state_lms, - anchor_in, - anchor_out, - target_lms_peak, - contrast_power, - target_cone_response, - hue_method, - observer_gamut_mode); - } else if (hull_method == PSYCHO25_HULL_METHOD_TARGET_RGB_CLIP) { - // Clip remains the literal component-clamp comparison applied to the - // ordinary physical/Graph result. It is intentionally distinct from the - // independent post-contrast experiments above. - output_lms = psycho25_ApplyPhysicalPerConePath( - contrast_lms, - signed_direction_source_lms, - current_adaptive_state_lms, - anchor_in, - anchor_out, - target_lms_peak, - contrast_power, - target_cone_response, - hue_method, - observer_gamut_mode); - float3 post_target_rgb = psycho25_TargetRGBFromLMS( - output_lms, - normalized_target_gamut_mode); - if ((resolved_gamut_enforcement - & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0) { - post_target_rgb = max(post_target_rgb, 0.f.xxx); - } - if ((resolved_gamut_enforcement - & PSYCHO25_GAMUT_ENFORCEMENT_PEAK) != 0) { - post_target_rgb = min(post_target_rgb, peak_value.xxx); - } - output_lms = psycho25_LMSFromTargetRGB( - post_target_rgb, - normalized_target_gamut_mode); - } else { - // Target-plane mode uses the requested primary and/or peak constraints. - // The per-channel curve remains the direct output compressor only in the - // disabled branch above. Here the complete no-gamut result supplies only - // the adaptive-MB trajectory; its scale is discarded before target-plane - // correction, so it is not a second output curve. Guidance direction and - // its cone-response state remain separate from physical target magnitude. - float target_peak_yf = psycho25_SignedYfFromLMS(target_lms_peak); - float resolved_guidance_peak_yf = psycho25_ResolveGuidancePeakYf( - target_peak_yf, - guidance_peak_scale); - float3 guidance_lms_peak = - target_lms_peak * (resolved_guidance_peak_yf / target_peak_yf); - float guidance_compression_power = target_compression_power; - if (compression == PSYCHO25_AUTO_COMPRESSION_SENTINEL) { - guidance_compression_power = - psycho25_AutoCompressionFromCenteredReferenceRange( - psycho25_YfFromLMS(anchor_out), - resolved_guidance_peak_yf); - } - Psycho25ConeResponseParameters guidance_cone_response = - psycho25_PrepareConeResponseParameters( - anchor_out, - guidance_lms_peak, - contrast_power, - guidance_compression_power, - safe_encoded_response_power); - output_lms = psycho25_CompressTargetHull( - contrast_lms, - signed_direction_source_lms, - current_adaptive_state_lms, - anchor_in, - anchor_out, - target_lms_peak, - guidance_lms_peak, - contrast_power, - resolved_upper_plane_shoulder_power, - target_cone_response, - guidance_cone_response, - peak_value, - normalized_target_gamut_mode, - resolved_gamut_enforcement, - hue_method, - hull_method, - upper_hull_pivot, - canonical_pressure_pivot, - canonical_pressure_contrast, - canonical_pressure_h, - canonical_pressure_trade, - canonical_yf_bias_power, - observer_gamut_mode); - } - - if (post_compression_mode - == PSYCHO25_POST_COMPRESSION_SOURCE_BT709_RESIDUAL) { - output_lms = psycho25_RestoreSourceBT709ResidualDirection( - output_lms, - signed_direction_source_lms, - peak_value, - normalized_target_gamut_mode, - resolved_gamut_enforcement); - } else if (use_adaptive_contrast_fit) { - output_lms = psycho25_ApplyAdaptiveContrastFit( - output_lms, - contrast_lms, - signed_direction_source_lms, - current_adaptive_state_lms, - anchor_in, - anchor_out, - target_lms_peak, - contrast_power, - target_cone_response, - hue_method, - peak_value, - normalized_target_gamut_mode, - resolved_gamut_enforcement, - observer_gamut_mode); - } else if (use_psychov17_gamut) { - if (gamut_compression != 0.f) { - // Match PsychoV17's final device map exactly on the completed physical - // output before any experiment-specific peak operation. - output_lms = psycho25_GamutCompressLMSBoundAdaptive( - output_lms, - current_adaptive_state_lms, - normalized_target_gamut_mode, - gamut_compression); - } - if (use_psychov17_neutwo_peak) { - // Retain the physical/Graph trajectory as direction so positive hue rays - // still converge to white. Derive only scalar magnitude from the - // unbounded post-contrast signal after the same PsychoV17 primary map; - // applying Neutwo directly to the already bounded physical magnitude - // would cap neutral at peak/sqrt(2). - float3 target_rgb = psycho25_TargetRGBFromLMS( - output_lms, - normalized_target_gamut_mode); - float3 magnitude_lms = contrast_lms; - if (gamut_compression != 0.f) { - magnitude_lms = psycho25_GamutCompressLMSBoundAdaptive( - magnitude_lms, - current_adaptive_state_lms, - normalized_target_gamut_mode, - gamut_compression); - } - float3 magnitude_target_rgb = psycho25_TargetRGBFromLMS( - magnitude_lms, - normalized_target_gamut_mode); - if ((resolved_gamut_enforcement - & PSYCHO25_GAMUT_ENFORCEMENT_PRIMARIES) != 0) { - target_rgb = max(target_rgb, 0.f.xxx); - magnitude_target_rgb = max(magnitude_target_rgb, 0.f.xxx); - } - float direction_max_channel = renodx::math::Max(abs(target_rgb)); - float magnitude_max_channel = renodx::math::Max( - abs(magnitude_target_rgb)); - float3 anchor_target_rgb = psycho25_TargetRGBFromLMS( - anchor_out, - normalized_target_gamut_mode); - float anchor_max_channel = min( - renodx::math::Max(abs(anchor_target_rgb)), - peak_value - PSYCHO25_EPSILON); - float anchor_input_max = renodx::tonemap::inverse::Neutwo( - anchor_max_channel, - peak_value); - float mapped_max_channel = renodx::tonemap::Neutwo( - magnitude_max_channel * renodx::math::DivideSafe( - anchor_input_max, - anchor_max_channel, - 1.f), - peak_value); - target_rgb *= renodx::math::DivideSafe( - mapped_max_channel, - direction_max_channel, - 1.f); - output_lms = psycho25_LMSFromTargetRGB( - target_rgb, - normalized_target_gamut_mode); - } else if (use_psychov17_nrg_white) { - // Retain the completed output's ACC-A scalar metric while replacing an - // over-peak selected-target RGB point with an in-cube point between its - // max-channel hue wall and peak D65 white. ACC-A here is an engineering - // scalar metric inherited from NRG Test7, not radiometric energy. - float3 target_rgb = max( - psycho25_TargetRGBFromLMS( - output_lms, - normalized_target_gamut_mode), - 0.f.xxx); - float max_target_channel = max( - target_rgb.x, - max(target_rgb.y, target_rgb.z)); - if (max_target_channel > peak_value) { - float3 target_bt2020 = normalized_target_gamut_mode == 0 - ? renodx::color::bt2020::from::BT709(target_rgb) - : target_rgb; - float3 hue_wall_target_rgb = target_rgb - * (peak_value / max_target_channel); - float3 hue_wall_bt2020 = normalized_target_gamut_mode == 0 - ? renodx::color::bt2020::from::BT709(hue_wall_target_rgb) - : hue_wall_target_rgb; - float scalar_output_raw; - target_bt2020 = renodx::tonemap::nrg::NRGTest7SolveWhiteSpillByScalarAccA( - hue_wall_bt2020, - peak_value, - renodx::tonemap::nrg::NRGTest7ScalarAccARaw( - target_bt2020, - peak_value), - scalar_output_raw); - target_rgb = normalized_target_gamut_mode == 0 - ? renodx::color::bt709::from::BT2020(target_bt2020) - : target_bt2020; - } - output_lms = psycho25_LMSFromTargetRGB( - target_rgb, - normalized_target_gamut_mode); - } - } - - return renodx::color::bt709::from::LMS(output_lms); -} - -} // namespace psychov -} // namespace tonemap -} // namespace renodx - -#endif // RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ diff --git a/src/games/elitedangerous/tonemap/test24.hlsli b/src/games/elitedangerous/tonemap/test24.hlsli deleted file mode 100644 index abaf7712e..000000000 --- a/src/games/elitedangerous/tonemap/test24.hlsli +++ /dev/null @@ -1,554 +0,0 @@ -#ifndef RENODX_ELITEDANGEROUS_TONEMAP_PSYCHOV_TEST24_HLSL_ -#define RENODX_ELITEDANGEROUS_TONEMAP_PSYCHOV_TEST24_HLSL_ - -#include "../common.hlsli" - -/* - * Copyright (C) 2026 Carlos Lopez - * SPDX-License-Identifier: MIT - */ - -namespace renodx_custom { -namespace tonemap { -namespace psychov { - -static const float PSYCHO24_EPSILON = 1e-6f; -static const float PSYCHO24_TWO_PI = 6.2831853071795864769f; -static const float PSYCHO24_REFERENCE_SIMULTANEOUS_RANGE_LOG10 = 3.7f; -static const float PSYCHO24_REFERENCE_CENTERED_RANGE_SIDE_COUNT = 2.f; -static const float PSYCHO24_HEADROOM_RATIO_FALLBACK = 1.f; -static const float PSYCHO24_MIN_AUTO_COMPRESSION = 1.f; -static const float PSYCHO24_MIN_MANUAL_COMPRESSION = 1e-6f; -static const float PSYCHO24_AUTO_COMPRESSION_SENTINEL = 0.f; -static const float PSYCHO24_HIGHLIGHT_GRADE_REFERENCE_WHITE = 1.f; -static const float PSYCHO24_SHADOW_GRADE_RANGE_STOPS = 4.f; - -float psycho24_YfFromLMS(float3 lms) { - float3 weighted_lms = - renodx::color::macleod_boynton::WeighLMS(lms); - return max(weighted_lms.x + weighted_lms.y, PSYCHO24_EPSILON); -} - -float psycho24_QuinticUnitRamp(float t) { - t = saturate(t); - return t * t * t * (t * (t * 6.f - 15.f) + 10.f); -} - -float psycho24_HighlightsScalarV4( - float x, - float highlights, - float adapted_anchor_yf) { - if (highlights == 1.f) { - return x; - } - - float t = 0.f; - if (x > adapted_anchor_yf) { - float reference_range_log2 = log2( - PSYCHO24_HIGHLIGHT_GRADE_REFERENCE_WHITE - / max(adapted_anchor_yf, PSYCHO24_EPSILON)); - t = saturate( - log2(x / max(adapted_anchor_yf, PSYCHO24_EPSILON)) - / max(reference_range_log2, PSYCHO24_EPSILON)); - } - t = psycho24_QuinticUnitRamp(t); - - float ratio = max( - x / max(adapted_anchor_yf, PSYCHO24_EPSILON), - PSYCHO24_EPSILON); - if (highlights > 1.f) { - return lerp( - x, - adapted_anchor_yf * pow(ratio, highlights), - t); - } - - float b = adapted_anchor_yf * pow(ratio, 2.f - highlights); - return renodx::math::DivideSafe(x * x, lerp(x, b, t), x); -} - -float psycho24_ShadowsScalarV4( - float x, - float shadows, - float adapted_anchor_yf) { - if (shadows == 1.f) { - return x; - } - - float ratio = max( - renodx::math::DivideSafe(x, adapted_anchor_yf, 0.f), - 0.f); - float base_term = x * adapted_anchor_yf; - float base_scale = renodx::math::DivideSafe(base_term, ratio, 0.f); - float shadow_floor = - adapted_anchor_yf * exp2(-PSYCHO24_SHADOW_GRADE_RANGE_STOPS); - - float t = 1.f; - if (x > shadow_floor) { - t = saturate( - log2(x / max(adapted_anchor_yf, PSYCHO24_EPSILON)) - / log2( - shadow_floor - / max(adapted_anchor_yf, PSYCHO24_EPSILON))); - } - t = psycho24_QuinticUnitRamp(t); - - if (shadows > 1.f) { - float raised = x * ( - 1.f + renodx::math::DivideSafe( - base_term, - pow(max(ratio, PSYCHO24_EPSILON), shadows), - 0.f)); - float reference = x * (1.f + base_scale); - return x + (raised - reference) * t; - } - - float lowered = x * ( - 1.f - renodx::math::DivideSafe( - base_term, - pow(max(ratio, PSYCHO24_EPSILON), 2.f - shadows), - 0.f)); - float reference = x * (1.f - base_scale); - return x + (lowered - reference) * t; -} - -float psycho24_AutoCompressionFromCenteredReferenceRange( - float anchor_out_yf, - float peak_yf) { - float peak_over_anchor = renodx::math::DivideSafe( - max(peak_yf, PSYCHO24_EPSILON), - max(anchor_out_yf, PSYCHO24_EPSILON), - PSYCHO24_HEADROOM_RATIO_FALLBACK); - peak_over_anchor = max( - peak_over_anchor, - 1.f + PSYCHO24_EPSILON); - - float reference_one_side_range_log10 = - PSYCHO24_REFERENCE_SIMULTANEOUS_RANGE_LOG10 - / PSYCHO24_REFERENCE_CENTERED_RANGE_SIDE_COUNT; - float actual_above_adaptation_range_log10 = max( - log10(peak_over_anchor), - PSYCHO24_EPSILON); - return max( - reference_one_side_range_log10 - / actual_above_adaptation_range_log10, - PSYCHO24_MIN_AUTO_COMPRESSION); -} - -float3 psycho24_ToAdaptiveRelativeWeightedLMS( - float3 lms_input, - float3 current_adaptive_state_lms) { - return renodx::math::DivideSafe( - renodx::color::macleod_boynton::WeighLMS(lms_input), - current_adaptive_state_lms, - 0.f.xxx); -} - -float3 psycho24_FromAdaptiveRelativeWeightedLMS( - float3 lms_weighted_relative, - float3 current_adaptive_state_lms) { - return lms_weighted_relative - * max(current_adaptive_state_lms, PSYCHO24_EPSILON.xxx); -} - -float3 psycho24_GamutCompressAdaptiveRelativeWeightedLMSBound( - float3 lms_weighted_relative_input, - float3 current_adaptive_state_lms, - float3x3 bound_rgb_to_lms_weighted_mat, - float strength) { - return renodx::color::gamut::GamutCompressWeightedLMSCoreRGBBoundFromAdaptiveWeightedInput( - lms_weighted_relative_input, - current_adaptive_state_lms, - bound_rgb_to_lms_weighted_mat, - strength); -} - -float3 psycho24_ApplyOKLabGamutHueDirection( - float3 gamut_mapped_bt709, - float3 pre_gamut_bt709, - int gamut_compression_mode) { - float3 mapped_oklab = renodx::color::oklab::from::BT709(gamut_mapped_bt709); - float3 source_oklab = renodx::color::oklab::from::BT709(pre_gamut_bt709); - - float mapped_radius2 = dot(mapped_oklab.yz, mapped_oklab.yz); - float source_radius2 = dot(source_oklab.yz, source_oklab.yz); - if (mapped_radius2 <= PSYCHO24_EPSILON - || source_radius2 <= PSYCHO24_EPSILON) { - return gamut_mapped_bt709; - } - - float mapped_radius = sqrt(mapped_radius2); - float2 source_direction = source_oklab.yz * rsqrt(source_radius2); - - float3 hue_restored_oklab = mapped_oklab; - hue_restored_oklab.yz = source_direction * mapped_radius; - float3 hue_restored_bt709 = - renodx::color::bt709::from::OkLab(hue_restored_oklab); - float3 hue_restored_bound_rgb = gamut_compression_mode == 1 - ? renodx::color::bt2020::from::BT709( - hue_restored_bt709) - : hue_restored_bt709; - - if (min(hue_restored_bound_rgb.x, - min(hue_restored_bound_rgb.y, hue_restored_bound_rgb.z)) < 0.f) { - float radius_inside = 0.f; - float radius_outside = mapped_radius; - - [unroll] - for (int i = 0; i < 10; ++i) { - float radius_test = 0.5f * (radius_inside + radius_outside); - float3 test_oklab = mapped_oklab; - test_oklab.yz = source_direction * radius_test; - float3 test_bt709 = renodx::color::bt709::from::OkLab(test_oklab); - float3 test_bound_rgb = gamut_compression_mode == 1 - ? renodx::color::bt2020::from::BT709( - test_bt709) - : test_bt709; - - if (min(test_bound_rgb.x, - min(test_bound_rgb.y, test_bound_rgb.z)) >= 0.f) { - radius_inside = radius_test; - } else { - radius_outside = radius_test; - } - } - - hue_restored_oklab.yz = source_direction * radius_inside; - hue_restored_bt709 = - renodx::color::bt709::from::OkLab(hue_restored_oklab); - } - - float mapped_yf = psycho24_YfFromLMS( - renodx::color::lms::from::BT709(gamut_mapped_bt709)); - float restored_yf = psycho24_YfFromLMS( - renodx::color::lms::from::BT709(hue_restored_bt709)); - return hue_restored_bt709 - * renodx::math::DivideSafe(mapped_yf, restored_yf, 1.f); -} - -float3 psycho24_ApplyAdaptiveMBPurity( - float3 lms_input, - float3 adaptive_neutral_lms, - float purity_delta) { - if (abs(purity_delta - 1.f) <= 1e-5f) { - return lms_input; - } - - float3 relative_weighted = - psycho24_ToAdaptiveRelativeWeightedLMS( - lms_input, - adaptive_neutral_lms); - float3 mb = - renodx::color::macleod_boynton::from::WeightedLMS( - relative_weighted); - float3 mb_neutral = - renodx::color::macleod_boynton::from::LMS(1.f.xxx); - float2 mb_scaled_xy = lerp( - mb_neutral.xy, - mb.xy, - purity_delta); - float3 relative_weighted_out = - renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton( - float3(mb_scaled_xy, mb.z)); - return renodx::color::macleod_boynton::UnweighLMS( - psycho24_FromAdaptiveRelativeWeightedLMS( - relative_weighted_out, - adaptive_neutral_lms)); -} - -// Position: source adaptive MacLeod-Boynton hue phase. -// Parameters: x = final direct -// normalize(lerp(compressed_direction, source_direction, x)) fraction; no V17 -// sensitivity or global hue-restore multiplier. -// y = inactive Phase 2 placeholder (semantics not yet chosen). -static const uint PSYCHO_MANUAL_HUE_COUNT = 23u; -static const float PSYCHO_MANUAL_HUE_POSITION[PSYCHO_MANUAL_HUE_COUNT] = { - 0.01071375f, 0.10705012f, 0.12795984f, 0.15335225f, 0.18766853f, 0.22076293f, - 0.24936653f, 0.27634237f, 0.29474511f, 0.31129214f, 0.35078118f, 0.39136371f, - 0.47262991f, 0.49426816f, 0.54698948f, 0.60705013f, 0.68311772f, 0.81129214f, - 0.91306421f, 0.93498424f, 0.94625976f, 0.96664602f, 0.97262991f, -}; -static const float2 PSYCHO_MANUAL_HUE_PARAMETERS[PSYCHO_MANUAL_HUE_COUNT] = { - float2(0.517681f, 0.500000f), // rose - float2(0.675575f, 0.500000f), // magenta - float2(0.691365f, 0.500000f), // fuchsia - float2(0.691365f, 0.500000f), // orchid - float2(0.665049f, 0.500000f), // purple - float2(0.680839f, 0.500000f), // violet - float2(0.661513f, 0.500000f), // indigo - float2(0.654523f, 0.500000f), // blue-violet - float2(0.648355f, 0.500000f), // ultramarine - float2(0.640461f, 0.500000f), // blue - float2(0.556250f, 0.500000f), // azure - float2(0.519408f, 0.500000f), // sky - float2(0.450987f, 0.500000f), // cyan - float2(0.435197f, 0.500000f), // teal - float2(0.424671f, 0.500000f), // spring - float2(0.464145f, 0.500000f), // green - float2(0.516776f, 0.500000f), // chartreuse - float2(0.608882f, 0.500000f), // yellow - float2(0.690461f, 0.500000f), // amber - float2(0.606250f, 0.500000f), // skin - float2(0.553618f, 0.500000f), // orange - float2(0.514145f, 0.500000f), // vermilion - float2(0.482566f, 0.500000f), // red -}; - -float psycho24_SampleManualHueLinearity(float source_hue_phase) { - source_hue_phase -= floor(source_hue_phase); - - uint lower_index = PSYCHO_MANUAL_HUE_COUNT - 1u; - [unroll] - for (uint i = 0u; i < PSYCHO_MANUAL_HUE_COUNT; ++i) { - if (source_hue_phase >= PSYCHO_MANUAL_HUE_POSITION[i]) { - lower_index = i; - } - } - - uint upper_index = (lower_index + 1u) % PSYCHO_MANUAL_HUE_COUNT; - float lower_position = PSYCHO_MANUAL_HUE_POSITION[lower_index]; - float upper_position = upper_index == 0u - ? PSYCHO_MANUAL_HUE_POSITION[0] + 1.f - : PSYCHO_MANUAL_HUE_POSITION[upper_index]; - if (upper_index == 0u && source_hue_phase < lower_position) { - source_hue_phase += 1.f; - } - - float t = saturate(renodx::math::DivideSafe( - source_hue_phase - lower_position, - upper_position - lower_position, - 0.f)); - return lerp( - PSYCHO_MANUAL_HUE_PARAMETERS[lower_index].x, - PSYCHO_MANUAL_HUE_PARAMETERS[upper_index].x, - t); -} - -float3 psycho24_ApplyManualHueDirection( - float3 compressed_lms, - float3 direction_source_lms, - float3 current_adaptive_state_lms, - float to_white_progress) { - float3 compressed_relative_weighted = - psycho24_ToAdaptiveRelativeWeightedLMS( - compressed_lms, - current_adaptive_state_lms); - float3 source_relative_weighted = - psycho24_ToAdaptiveRelativeWeightedLMS( - direction_source_lms, - current_adaptive_state_lms); - - float3 compressed_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - compressed_relative_weighted); - float3 source_mb = - renodx::color::macleod_boynton::from::WeightedLMS( - source_relative_weighted); - float2 adapted_neutral_mb = - renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - - float2 compressed_offset = compressed_mb.xy - adapted_neutral_mb; - float2 source_offset = source_mb.xy - adapted_neutral_mb; - float compressed_radius2 = dot(compressed_offset, compressed_offset); - float source_radius2 = dot(source_offset, source_offset); - if (compressed_radius2 <= PSYCHO24_EPSILON * PSYCHO24_EPSILON - || source_radius2 <= PSYCHO24_EPSILON * PSYCHO24_EPSILON) { - return compressed_lms; - } - - float source_hue_phase = - atan2(source_offset.y, source_offset.x) / PSYCHO24_TWO_PI; - source_hue_phase -= floor(source_hue_phase); - float amount = lerp( - 1.f, - psycho24_SampleManualHueLinearity(source_hue_phase), - saturate(to_white_progress)); - - float compressed_radius = sqrt(compressed_radius2); - float2 compressed_direction = compressed_offset / compressed_radius; - float2 source_direction = source_offset * rsqrt(source_radius2); - float2 output_direction = lerp(compressed_direction, source_direction, amount); - float output_direction2 = dot(output_direction, output_direction); - if (output_direction2 <= PSYCHO24_EPSILON * PSYCHO24_EPSILON) { - return compressed_lms; - } - output_direction *= rsqrt(output_direction2); - - float3 restored_mb = float3( - adapted_neutral_mb + output_direction * compressed_radius, - compressed_mb.z); - float3 restored_relative_weighted = - renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton( - restored_mb); - return renodx::color::macleod_boynton::UnweighLMS( - psycho24_FromAdaptiveRelativeWeightedLMS( - restored_relative_weighted, - current_adaptive_state_lms)); -} - -// Test24 keeps Test23 grading, adaptive-MB purity, contrast, compression, -// CopySign, and gamut/output stages. Its only model change is replacing the -// pre-gamut OKLab hue operation with the authored adaptive-MB interpolation. -float3 psychotm_test24(float3 bt709_linear_input, float peak_value = 1000.f / 203.f, - float exposure = 1.f, - float highlights = 1.f, - float shadows = 1.f, - float contrast = 1.f, - float purity_scale = 1.f, - float bleaching_intensity = 1.f, - float clip_point = 100.f, - float hue_restore = 1.f, - float adaptation_contrast = 1.f, - int white_curve_mode = 0, - float cone_response_exponent = 1.f, - float3 current_adaptive_state_bt709 = 0.18f, - float3 current_background_state_bt709 = 0.18f, - float gamut_compression = 1.f, - int gamut_compression_mode = 1, - float adaptive_normalization = 1.f, - float compression = 1.f, - float highlight_saturation = 1.f, - float gamut_hue_restore = 0.f) { - float legacy_response_scale = cone_response_exponent * adaptation_contrast; - contrast *= legacy_response_scale; - purity_scale *= legacy_response_scale; - - float3 lms_in = - renodx::color::lms::from::BT709(bt709_linear_input * exposure); - float3 lms_peak = - renodx::color::lms::from::BT709(float(peak_value).xxx); - float3 current_adaptive_state_lms = - renodx::color::lms::from::BT709(current_adaptive_state_bt709); - float3 desired_background_state_lms = - renodx::color::lms::from::BT709(current_background_state_bt709); - - float3 lms_cones = lms_in; - float3 anchor_in = max(current_adaptive_state_lms, PSYCHO24_EPSILON.xxx); - float3 anchor_out = max(desired_background_state_lms, PSYCHO24_EPSILON.xxx); - float contrast_power = max(contrast, PSYCHO24_EPSILON); - - float3 graded_lms = abs(lms_cones); - float graded_yf = psycho24_YfFromLMS(graded_lms); - float adapted_anchor_yf = psycho24_YfFromLMS(anchor_in); - float graded_yf_out = - psycho24_HighlightsScalarV4( - graded_yf, - highlights, - adapted_anchor_yf); - graded_yf_out = psycho24_ShadowsScalarV4( - graded_yf_out, - shadows, - adapted_anchor_yf); - graded_lms *= renodx::math::DivideSafe( - graded_yf_out, - graded_yf, - 1.f); - - float purity_delta = renodx::math::DivideSafe( - max(purity_scale, PSYCHO24_EPSILON), - contrast_power, - 1.f); - float3 contrast_input = - psycho24_ApplyAdaptiveMBPurity( - graded_lms, - anchor_in, - purity_delta); - - float3 contrast_ratio = - max(contrast_input / anchor_in, PSYCHO24_EPSILON.xxx); - float3 contrast_lms = - anchor_out * pow(contrast_ratio, contrast_power); - - float compression_power = compression; - if (compression == PSYCHO24_AUTO_COMPRESSION_SENTINEL) { - compression_power = psycho24_AutoCompressionFromCenteredReferenceRange( - psycho24_YfFromLMS(anchor_out), - psycho24_YfFromLMS(lms_peak)); - } - compression_power = max( - compression_power, - PSYCHO24_MIN_MANUAL_COMPRESSION); - - float3 anchor_over_peak = - anchor_out / max(lms_peak, PSYCHO24_EPSILON.xxx); - float3 compression_slope_norm = - 1.f - pow( - max(anchor_over_peak, PSYCHO24_EPSILON.xxx), - compression_power); - float3 compression_input = pow( - max(contrast_lms / anchor_out, PSYCHO24_EPSILON.xxx), - compression_power - / max(compression_slope_norm, PSYCHO24_EPSILON.xxx)); - float3 compression_white_offset = - pow( - max(lms_peak / anchor_out, PSYCHO24_EPSILON.xxx), - compression_power) - - 1.f; - float3 compression_rolloff = pow( - compression_input - / max( - compression_input + compression_white_offset, - PSYCHO24_EPSILON.xxx), - rcp(compression_power)); - float3 compressed_lms = lms_peak * compression_rolloff; - float to_white_progress = max( - compression_rolloff.x, - max(compression_rolloff.y, compression_rolloff.z)); - - float3 hue_restored_lms = psycho24_ApplyManualHueDirection( - compressed_lms, - contrast_input, - current_adaptive_state_lms, - to_white_progress); - float3 display_scaled = - renodx::math::CopySign(hue_restored_lms, lms_cones); - float3 display_scaled_relative_weighted = - psycho24_ToAdaptiveRelativeWeightedLMS( - display_scaled, - current_adaptive_state_lms); - - if (gamut_compression != 0.f) { - if (gamut_compression_mode == 0) { - display_scaled_relative_weighted = - psycho24_GamutCompressAdaptiveRelativeWeightedLMSBound( - display_scaled_relative_weighted, - current_adaptive_state_lms, - renodx::color::macleod_boynton::BT709_TO_LMS_WEIGHTED_MAT, - gamut_compression); - } else if (gamut_compression_mode == 1) { - display_scaled_relative_weighted = - psycho24_GamutCompressAdaptiveRelativeWeightedLMSBound( - display_scaled_relative_weighted, - current_adaptive_state_lms, - renodx::color::macleod_boynton::BT2020_TO_LMS_WEIGHTED_MAT, - gamut_compression); - } - } - - float3 final_bt709 = renodx::color::bt709::from::LMS( - renodx::color::macleod_boynton::UnweighLMS( - psycho24_FromAdaptiveRelativeWeightedLMS( - display_scaled_relative_weighted, - current_adaptive_state_lms))); - - if (gamut_compression != 0.f && gamut_hue_restore != 0.f) { - float3 pre_gamut_bt709 = - renodx::color::bt709::from::LMS(display_scaled); - float3 hue_fixed_bt709 = psycho24_ApplyOKLabGamutHueDirection( - final_bt709, - pre_gamut_bt709, - gamut_compression_mode); - final_bt709 = lerp( - final_bt709, - hue_fixed_bt709, - saturate(gamut_hue_restore)); - } - - return final_bt709; -} - -} // namespace psychov -} // namespace tonemap -} // namespace renodx - -#endif // RENODX_ELITEDANGEROUS_TONEMAP_PSYCHOV_TEST24_HLSL_ \ No newline at end of file diff --git a/src/games/elitedangerous/tonemap/tonemap.hlsli b/src/games/elitedangerous/tonemap/tonemap.hlsli index 888a8cb7f..abfe9be56 100644 --- a/src/games/elitedangerous/tonemap/tonemap.hlsli +++ b/src/games/elitedangerous/tonemap/tonemap.hlsli @@ -1,6 +1,5 @@ #include "../common.hlsli" -#include "./psychov25/test25.hlsli" -#include "./test24.hlsli" +#include "./psychov25/customtest25.hlsli" static const float MID_GRAY_IN = 0.119121851127f; static const float MID_GRAY_OUT = 0.163979921774f; @@ -88,501 +87,6 @@ float3 ApplyPurityGradingBT2020(float3 color_bt2020, float purity_scale, float h return renodx::color::bt2020::from::LMS(color_lms); } -float3 ComputeCInfinityTransition(float3 position) { - position = saturate(position); - return 1.f / (1.f + exp2((1.f - 2.f * position) / (position * (1.f - position)))); -} - -// Monotonic and C-infinity continuous anchored tonal grading -float3 ApplyAnchoredTonalGrading( - float3 color, - float3 anchor_in = 0.18f, - float3 anchor_out = 0.18f, - float contrast = 1.f, - float flare = 0.f, - float highlight_contrast = 1.f, - float shadow_contrast = 1.f, - float highlights = 1.f, - float shadows = 1.f) { - [branch] - if (contrast == 1.f - && flare == 0.f - && highlight_contrast == 1.f - && shadow_contrast == 1.f - && highlights == 1.f - && shadows == 1.f - && all(anchor_in == anchor_out)) { - return color; - } - - float3 ax = abs(color); - float3 normalized = ax / anchor_in; - float3 contrasted_normalized = normalized; - - // Power contrast and shadow flare, optionally bounding contrast on highlights. - [branch] - if (contrast != 1.f || flare > 0.f) { - float3 exponent = contrast; - - [branch] - if (flare > 0.f) { - float3 shadow_distance = saturate(1.f - normalized); - float3 flat_shadow_weight = exp2(-normalized / shadow_distance); - exponent *= mad(flat_shadow_weight, flare / (normalized + flare), 1.f); - } - -#if 1 - float3 input_stops = log2(normalized); - float3 highlight_stops = max(input_stops, 0.f); - float3 output_highlight_stops = highlight_stops; - - [branch] - if (contrast != 1.f) { - float3 contrast_displacement = (contrast - 1.f) * highlight_stops; - float3 displacement_magnitude = abs(contrast_displacement); - output_highlight_stops += contrast_displacement / mad(displacement_magnitude, exp2(-1.f / displacement_magnitude), 1.f); - } - - contrasted_normalized = exp2(mad(exponent, min(input_stops, 0.f), output_highlight_stops)); -#else - contrasted_normalized = pow(normalized, exponent); -#endif - } - - // broad highlight contrast. - [branch] - if (highlight_contrast != 1.f) { - float3 highlight_distance = max(contrasted_normalized - 1.f, 0.f); - float3 highlight_distance_squared = highlight_distance * highlight_distance; - float3 flat_highlight_distance = (1.f + highlight_distance_squared) * exp2(-1.f / highlight_distance_squared); - contrasted_normalized += highlight_distance * (pow(1.f + flat_highlight_distance, 0.5f * (highlight_contrast - 1.f)) - 1.f); - } - - // broad shadow contrast. - [branch] - if (shadow_contrast != 1.f) { - float3 shadow_distance = saturate(1.f - contrasted_normalized); - float3 shadow_distance_squared = shadow_distance * shadow_distance; - float3 flat_shadow_distance = shadow_distance_squared * shadow_distance * exp2(1.f - 1.f / shadow_distance_squared); - contrasted_normalized *= pow(1.f + flat_shadow_distance, shadow_contrast - 1.f); - } - - // Mirror offsets about the anchor over the declared stop range. - [branch] - if (highlights != 1.f || shadows != 1.f) { - static const float TONAL_OFFSET_START_STOPS = 1.f; - static const float TONAL_OFFSET_END_STOPS = 8.f; - static const float TONAL_OFFSET_INVERSE_RANGE_STOPS = 1.f / (TONAL_OFFSET_END_STOPS - TONAL_OFFSET_START_STOPS); - - float3 tonal_stops = log2(contrasted_normalized); - float3 tonal_displacement = 0.f; - - [branch] - if (highlights != 1.f) { - float highlight_adjustment = highlights - 1.f; - float highlight_displacement = highlight_adjustment * mad(1.5f, abs(highlight_adjustment), 0.5f); - float3 highlight_weight = ComputeCInfinityTransition((tonal_stops - TONAL_OFFSET_START_STOPS) * TONAL_OFFSET_INVERSE_RANGE_STOPS); - tonal_displacement = mad(highlight_displacement, highlight_weight, tonal_displacement); - } - - [branch] - if (shadows != 1.f) { - float shadow_adjustment = shadows - 1.f; - float shadow_displacement = shadow_adjustment * mad(1.5f, abs(shadow_adjustment), 0.5f); - float3 shadow_weight = ComputeCInfinityTransition((-TONAL_OFFSET_START_STOPS - tonal_stops) * TONAL_OFFSET_INVERSE_RANGE_STOPS); - tonal_displacement = mad(shadow_displacement, shadow_weight, tonal_displacement); - } - - contrasted_normalized *= exp2(tonal_displacement); - } - - return renodx::math::CopySign(contrasted_normalized * anchor_out, color); -} - -/// Identity through anchor to every derivative; then approaches peak -/// monotonically and concave down. Requires anchor < peak and compression_strength >= 1. -#define APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(T) \ - T ApplyAnchoredCInfinityShoulder(T color, T peak, T anchor, float compression_strength) { \ - T shoulder_range = peak - anchor; \ - T distance_from_anchor = max(color - anchor, (T)0.f); \ - T flat_weight = exp2(-shoulder_range / (compression_strength * distance_from_anchor)); \ - T response_denominator = mad(distance_from_anchor, flat_weight, shoulder_range); \ - return mad(shoulder_range, distance_from_anchor / response_denominator, color - distance_from_anchor); \ - } - -APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float) -APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float3) -#undef APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR - -float ApplyAnchoredCInfinityShoulderMaxChannelScale(float3 color, float peak, float anchor, float compression_strength) { - float max_channel = renodx::math::Max(abs(color)); - float compressed_max = ApplyAnchoredCInfinityShoulder(max_channel, peak, anchor, compression_strength); - return renodx::math::DivideSafe(compressed_max, max_channel, 1.f); -} - -/// Identity at and below anchor; C-infinity generalized Naka-Rushton above it. -/// Requires anchor < peak, compression_power > 1, and 0 < response_coefficient <= 1. -#define APPLY_CINFINITY_NAKA_RUSHTON_GENERATOR(T) \ - T ApplyCInfinityNakaRushton(T color, T peak, T anchor, float compression_power = 1.f, float response_coefficient = 0.001f) { \ - float inverse_compression_power = rcp(compression_power); \ - float flat_response_numerator = -1.f / log(2.f) * response_coefficient; \ - T shoulder_range = peak - anchor; \ - T distance_from_anchor = max(color - anchor, (T)0.f); \ - T position = distance_from_anchor / shoulder_range; \ - T position_power = pow(position, compression_power); \ - T flat_response = exp2(flat_response_numerator * rcp(mad(position_power, position_power, position_power))); \ - T response_scale = pow(mad(position_power, flat_response, (T)1.f), -inverse_compression_power); \ - return mad(distance_from_anchor, response_scale, color - distance_from_anchor); \ - } -APPLY_CINFINITY_NAKA_RUSHTON_GENERATOR(float) -APPLY_CINFINITY_NAKA_RUSHTON_GENERATOR(float3) -#undef APPLY_CINFINITY_NAKA_RUSHTON_GENERATOR - -// Fixed PsychoV25 target-hull path: Fast60 hue guidance, Reference Scale, -// full BT.2020 lower/upper-plane enforcement, and a black upper-hull pivot. -float3 CompressPsychoV25ReferenceScaleHull( - float3 desired_lms, - float3 direction_source_lms, - float3 adaptive_state_lms, - float3 background_state_lms, - float3 target_lms_peak, - float source_direction_recovery_strength, - float naka_rushton_compression, - float cinfinity_shoulder_compression, - int white_curve_mode, - float cone_response_exponent, - float peak_value) { - float3 desired_weighted_lms = renodx::color::macleod_boynton::WeighLMS(desired_lms); - float desired_yf = desired_weighted_lms.x + desired_weighted_lms.y; - if (desired_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { - return 0.f.xxx; - } - - float adaptive_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(adaptive_state_lms); - float background_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(background_state_lms); - float target_peak_yf = renodx::tonemap::psychov::psycho25_SignedYfFromLMS(target_lms_peak); - float3 physical_compressed_lms; - [branch] - if (white_curve_mode == 1) { - renodx::tonemap::psychov::Psycho25ConeResponseParameters cone_response = - renodx::tonemap::psychov::psycho25_PrepareConeResponseParameters( - background_state_lms, - target_lms_peak, - cone_response_exponent, - naka_rushton_compression, - 1.f); - renodx::tonemap::psychov::Psycho25ConeResponseState response_state = - renodx::tonemap::psychov::psycho25_BuildConeResponseState( - desired_lms, - cone_response); - float3 normalized_response = response_state.encoded_response - / (abs(response_state.encoded_response) - + response_state.encoded_peak_offset); - float3 compressed_response = cone_response.inverse_compression_power == 1.f - ? normalized_response - : renodx::math::SignPow( - normalized_response, - cone_response.inverse_compression_power); - physical_compressed_lms = target_lms_peak * compressed_response; - } else { - physical_compressed_lms = ApplyAnchoredCInfinityShoulder( - desired_lms, - target_lms_peak, - background_state_lms, - cinfinity_shoulder_compression); - } - float authored_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(physical_compressed_lms); - if (authored_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { - return 0.f.xxx; - } - - float3 safe_adaptive_state_lms = max( - adaptive_state_lms, - renodx::tonemap::psychov::PSYCHO25_EPSILON.xxx); - float2 adapted_neutral_mb = renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - float3 authored_mb = renodx::color::macleod_boynton::from::WeightedLMS( - renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( - physical_compressed_lms, - adaptive_state_lms)); - float3 source_mb = renodx::color::macleod_boynton::from::WeightedLMS( - renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( - direction_source_lms, - adaptive_state_lms)); - - // Fast60: retain physical radius and use the angular midpoint between the - // source direction and the raw per-cone-compressed direction. - float2 authored_offset = authored_mb.xy - adapted_neutral_mb; - float2 source_offset = source_mb.xy - adapted_neutral_mb; - float authored_radius2 = dot(authored_offset, authored_offset); - float source_radius2 = dot(source_offset, source_offset); - if (authored_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON - && source_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON) { - float2 source_direction = source_offset * rsqrt(source_radius2); - float2 compressed_direction = authored_offset * rsqrt(authored_radius2); - float2 output_direction = lerp( - source_direction, - compressed_direction, - 1.f - renodx::tonemap::psychov::PSYCHO25_HUE_AMPLITUDE); - float output_direction2 = dot(output_direction, output_direction); - if (output_direction2 > renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON) { - authored_mb.xy = adapted_neutral_mb - + output_direction * rsqrt(output_direction2) * sqrt(authored_radius2); - authored_offset = authored_mb.xy - adapted_neutral_mb; - authored_radius2 = dot(authored_offset, authored_offset); - } - } - - float authored_radius = sqrt(authored_radius2); - float2 authored_direction = authored_offset * rsqrt(authored_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); - - // Reference Scale source-direction recovery keeps collapsing saturated - // highlights from rotating through an unrelated hue on their way to white. - [branch] - if (source_direction_recovery_strength > 0.f) { - float source_radius = sqrt(source_radius2); - float2 source_direction = source_offset * rsqrt(source_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); - float source_radius_support = - renodx::tonemap::psychov::psycho25_TargetLowerPlaneRadiusForDirection( - source_direction, - adapted_neutral_mb, - adaptive_state_lms, - 1); - float source_direction_support_radius = - renodx::tonemap::psychov::PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY - * source_radius_support - * renodx::math::DivideSafe( - source_radius, - sqrt(source_radius2 + source_radius_support * source_radius_support), - 0.f); - float radius_normalization = max( - max(authored_radius, source_direction_support_radius), - renodx::tonemap::psychov::PSYCHO25_EPSILON); - float authored_weight = pow( - authored_radius / radius_normalization, - renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); - float source_direction_support_weight = pow( - source_direction_support_radius / radius_normalization, - renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); - float source_hue_support = - renodx::tonemap::psychov::PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION - * source_radius_support; - float source_hue_confidence = renodx::math::DivideSafe( - source_radius2, - source_radius2 + source_hue_support * source_hue_support, - 0.f); - float source_collapse_weight = renodx::math::DivideSafe( - source_direction_support_weight, - authored_weight + source_direction_support_weight, - 0.f); - float source_direction_weight = source_direction_recovery_strength - * (1.f - (1.f - source_hue_confidence) * (1.f - source_collapse_weight)); - float2 combined_direction = lerp( - authored_direction, - source_direction, - source_direction_weight); - combined_direction *= rsqrt( - dot(combined_direction, combined_direction) - + renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON); - authored_direction = combined_direction; - authored_offset = authored_direction * authored_radius; - authored_mb.xy = adapted_neutral_mb + authored_offset; - } - - // Discard the trajectory's carried scale, preserving only its authored - // adaptive-MB direction and radius before solving the BT.2020 hull. - float trajectory_yf_for_normalization = authored_mb.z - * (authored_mb.x * safe_adaptive_state_lms.x - + (1.f - authored_mb.x) * safe_adaptive_state_lms.y); - float3 unit_yf_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( - float3( - authored_mb.xy, - renodx::math::DivideSafe( - authored_mb.z, - trajectory_yf_for_normalization, - 0.f)), - adaptive_state_lms); - float3 neutral_lms = adaptive_state_lms / adaptive_yf; - - // Reference Scale lower-plane compression keeps the authored hue ray inside - // the nonnegative BT.2020 primary half-spaces without a component clamp. - if (authored_radius > renodx::tonemap::psychov::PSYCHO25_EPSILON) { - float3 neutral_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(neutral_lms, 1); - float3 current_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); - float current_boundary_fraction = - renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( - current_target_rgb, - neutral_target_rgb); - float current_radius_scale = - renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( - current_boundary_fraction); - - authored_direction = authored_offset / authored_radius; - float containment_reference_radius = max( - authored_radius, - length(source_mb.xy - adapted_neutral_mb)); - float3 reference_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( - float3( - adapted_neutral_mb - + authored_direction * containment_reference_radius, - 1.f), - adaptive_state_lms); - reference_lms /= renodx::tonemap::psychov::psycho25_YfFromLMS(reference_lms); - float3 reference_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(reference_lms, 1); - float reference_boundary_fraction = - renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( - reference_target_rgb, - neutral_target_rgb); - float reference_radius_scale = - renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( - reference_boundary_fraction); - - float trajectory_fraction = authored_radius / containment_reference_radius; - float release_progress = saturate( - trajectory_fraction - / renodx::tonemap::psychov::PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION); - float neutral_scale = min(1.f, 4.f * reference_radius_scale); - float release_weight = 1.f - release_progress; - float radius_scale = min( - lerp( - reference_radius_scale, - neutral_scale, - release_weight * release_weight), - current_radius_scale); - unit_yf_lms = lerp(neutral_lms, unit_yf_lms, radius_scale); - } - - // Black-pivot upper-plane shoulder along the contained BT.2020 hue ray. - float3 unit_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); - float max_target_channel = max( - unit_target_rgb.x, - max(unit_target_rgb.y, unit_target_rgb.z)); - float directional_yf_limit = peak_value / max_target_channel; - float normalized_input = desired_yf * renodx::math::DivideSafe(target_peak_yf, directional_yf_limit, 1.f); - float normalized_output; - [branch] - if (white_curve_mode == 1) { - normalized_output = ApplyCInfinityNakaRushton( - normalized_input, - target_peak_yf, - background_yf, - naka_rushton_compression, - 0.001f); - } else { - normalized_output = ApplyAnchoredCInfinityShoulder( - normalized_input, - target_peak_yf, - background_yf, - cinfinity_shoulder_compression); - } - float output_yf = normalized_output * renodx::math::DivideSafe(directional_yf_limit, target_peak_yf, 1.f); - return unit_yf_lms * output_yf; -} - -float3 ApplyCustomPsychoV25ToneMap( - float3 bt709_linear_input, - float peak_value, - float highlights, - float shadows, - float cone_response_exponent, - float flare, - float purity_scale, - float highlight_saturation, - float dechroma, - float source_direction_recovery_strength = 0.f, - float3 current_adaptive_state_bt709 = 0.18f, - float3 current_background_state_bt709 = 0.18f, - int white_curve_mode = 0, - float naka_rushton_compression = 0.f, - float cinfinity_shoulder_compression = 1.5f) { - float3 finite_bt709_input = renodx::math::ZeroNaN(bt709_linear_input); - finite_bt709_input = renodx::math::Select( - isinf(finite_bt709_input), - renodx::math::CopySign(65504.f.xxx, finite_bt709_input), - finite_bt709_input); - - float3 lms_in = renodx::color::lms::from::BT709(finite_bt709_input); - float3 current_adaptive_state_lms = - renodx::color::lms::from::BT709(current_adaptive_state_bt709); - float3 current_background_state_lms = - renodx::color::lms::from::BT709(current_background_state_bt709); - float3 target_lms_peak = renodx::color::lms::from::BT709(peak_value.xxx); - - if (dechroma != 0.f || highlight_saturation != 1.f) { - float luminance = renodx::color::yf::from::LMS(lms_in); - float neutral_luminance = renodx::color::yf::from::LMS(current_adaptive_state_lms); - - // Ramp purity grading over 2.75 decades above the adaptive neutral. - static const float INVERSE_HIGHLIGHT_RANGE_STOPS = 1.f / (2.75f * log2(10.f)); - static const float HIGHLIGHT_ROLLOFF_CUBIC_BLEND = 0.5f; - static const float HIGHLIGHT_PURITY_STRENGTH = 2.f / 3.f; - - float luminance_from_neutral = max(luminance, neutral_luminance) / neutral_luminance; - float rolloff_position = saturate(log2(luminance_from_neutral) * INVERSE_HIGHLIGHT_RANGE_STOPS); - float rolloff_position_squared = rolloff_position * rolloff_position; - float rolloff = rolloff_position_squared * rolloff_position * mad(rolloff_position, mad(6.f, rolloff_position, -15.f), 10.f); - - // Base smootherstep brings dechroma into the midtones while remaining monotonic and C2. - if (dechroma != 0.f) { - purity_scale *= mad(-dechroma, rolloff, 1.f); - } - - // Blend smootherstep squared and cubed for a later, gentler C2 progression. - if (highlight_saturation != 1.f) { - float highlight_rolloff = rolloff * rolloff * mad(HIGHLIGHT_ROLLOFF_CUBIC_BLEND, rolloff, 1.f - HIGHLIGHT_ROLLOFF_CUBIC_BLEND); - purity_scale *= mad(highlight_saturation - 1.f, highlight_rolloff * HIGHLIGHT_PURITY_STRENGTH, 1.f); - } - } - - float3 contrast_input = renodx::tonemap::psychov::psycho25_ApplyAdaptiveMBPurity( - lms_in, - current_adaptive_state_lms, - purity_scale); - float3 contrast_lms = ApplyAnchoredTonalGrading( - contrast_input, - current_adaptive_state_lms, - current_background_state_lms, - cone_response_exponent, - flare, - 1.f, - 1.f, - highlights, - shadows); - - float naka_rushton_compression_power = naka_rushton_compression; - if (white_curve_mode == 1) { - if (naka_rushton_compression == renodx::tonemap::psychov::PSYCHO25_AUTO_COMPRESSION_SENTINEL) { - naka_rushton_compression_power = renodx::tonemap::psychov::psycho25_AutoCompressionFromCenteredReferenceRange( - renodx::tonemap::psychov::psycho25_YfFromLMS(current_background_state_lms), - renodx::tonemap::psychov::psycho25_YfFromLMS(target_lms_peak)); - } - naka_rushton_compression_power = max( - naka_rushton_compression_power, - renodx::tonemap::psychov::PSYCHO25_MIN_MANUAL_COMPRESSION); - } - - float3 output_lms = CompressPsychoV25ReferenceScaleHull( - contrast_lms, - contrast_input, - current_adaptive_state_lms, - current_background_state_lms, - target_lms_peak, - source_direction_recovery_strength, - naka_rushton_compression_power, - cinfinity_shoulder_compression, - white_curve_mode, - cone_response_exponent, - peak_value); - return renodx::color::bt709::from::LMS(output_lms); -} - /// Elite Dangerous vanilla SDR tonemapper. /// Output is in gamma space. #define APPLY_VANILLA_TONEMAP_GENERATOR(T) \ @@ -725,7 +229,7 @@ float3 ApplyPostLUTToneMap(float3 untonemapped_gamma) { 0.f, MID_GRAY_IN, MID_GRAY_OUT, - 0, 1.f, 1.5f); + 1.5f); } return renodx::color::gamma::EncodeSafe(tonemapped, 2.2f); From b69d504930e8fa7f6b8d48a5114273fca65e53e0 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Wed, 12 Aug 2026 00:11:10 -0400 Subject: [PATCH 06/22] refactor(asscreedblackflagresynced): simplify Anvil Engine tonemap --- .../tonemap/tonemap.hlsli | 133 ++++++------------ 1 file changed, 45 insertions(+), 88 deletions(-) diff --git a/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli b/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli index 66134658c..d39ba051a 100644 --- a/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli +++ b/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli @@ -1,65 +1,34 @@ #include "../common.hlsli" -struct ImmortalsToneMapConfig { - float slope; - float toe_threshold; - float toe_slope; - float black_offset; - float peak_luminance; - float shoulder_start; - float shoulder_scale; - float shoulder_overage; - bool has_toe; -}; - -ImmortalsToneMapConfig CreateImmortalsToneMapConfig( - float slope, - float toe_threshold, - float shoulder_start, - float toe_slope, - float black_offset, - float peak_nits) { - ImmortalsToneMapConfig config; - config.slope = slope; - config.toe_threshold = toe_threshold; - config.toe_slope = toe_slope; - config.black_offset = black_offset; - config.peak_luminance = peak_nits * 0.00999999977648258209228515625f; - config.has_toe = config.toe_threshold > 9.9999997473787516355514526367188e-06f; - - float toe_to_peak_range = mad(peak_nits, 0.00999999977648258209228515625f, -config.toe_threshold); - float shoulder_start_output = mad(toe_to_peak_range, shoulder_start, config.toe_threshold); - config.shoulder_start = ((toe_to_peak_range * shoulder_start) / config.slope) + config.toe_threshold; - config.shoulder_scale = (config.peak_luminance * config.slope) / mad(peak_nits, 0.00999999977648258209228515625f, -shoulder_start_output); - config.shoulder_overage = mad(-peak_nits, 0.00999999977648258209228515625f, shoulder_start_output); - return config; -} - -#define IMMORTALS_TONEMAP_GENERATOR(T) \ - T ApplyImmortalsToneMap(T untonemapped_ap1, ImmortalsToneMapConfig config, out T precompression_ap1) { \ - T input_scaled = abs(untonemapped_ap1 * 0.00999999977648258209228515625f); \ - T toe_ratio = input_scaled / config.toe_threshold; \ - T toe_ratio_sat = saturate(toe_ratio); \ - T toe_ratio_sat_sq = toe_ratio_sat * toe_ratio_sat; \ - T toe_smooth = mad(toe_ratio_sat, -2.f, 3.f); \ - T in_shoulder = renodx::math::Select(input_scaled > config.shoulder_start, (T)1.f, (T)0.f); \ - T toe_curve = renodx::math::Select(config.has_toe, mad(exp2(log2(abs(toe_ratio)) * config.toe_slope), config.toe_threshold, config.black_offset), config.black_offset); \ - T toe_weight = mad(-toe_smooth, toe_ratio_sat_sq, 1.f); \ - T linear_curve = mad(input_scaled - config.toe_threshold, config.slope, config.toe_threshold); \ - T linear_weight = mad(toe_smooth, toe_ratio_sat_sq, -1.f) + 1.f; \ - T precompression_curve = (toe_weight * toe_curve) + (linear_weight * linear_curve); \ - T shoulder_curve = config.peak_luminance + (exp2(((config.shoulder_scale * (input_scaled - config.shoulder_start)) / config.peak_luminance) * (-1.44269502162933349609375f)) * config.shoulder_overage); \ - precompression_ap1 = precompression_curve * 100.f; \ - return lerp(precompression_curve, shoulder_curve, in_shoulder) * 100.f; \ - } \ - T ApplyImmortalsToneMap(T untonemapped_ap1, ImmortalsToneMapConfig config) { \ - T precompression_ap1; \ - return ApplyImmortalsToneMap(untonemapped_ap1, config, precompression_ap1); \ +#define ANVIL_ENGINE_TONEMAP_GENERATOR(T) \ + T EvaluateAnvilEngineToeAndLinear(T input, float linear_slope, float toe_end, float toe_power, float toe_offset) { \ + T input_abs = abs(input); \ + bool toe_enabled = toe_end > 1e-5f; \ + T toe_progress_unclamped = input_abs / toe_end; \ + T toe_progress = saturate(toe_progress_unclamped); \ + T toe_progress_squared = toe_progress * toe_progress; \ + T smoothstep_factor = mad(toe_progress, -2.f, 3.f); \ + T toe_output = renodx::math::Select(toe_enabled, mad(pow(abs(toe_progress_unclamped), toe_power), toe_end, toe_offset), toe_offset); \ + T toe_blend_weight = mad(-smoothstep_factor, toe_progress_squared, 1.f); \ + T linear_output = mad(input_abs - toe_end, linear_slope, toe_end); \ + T toe_to_linear_blend = mad(smoothstep_factor, toe_progress_squared, -1.f) + 1.f; \ + T toe_linear_output = (toe_blend_weight * toe_output) + (toe_to_linear_blend * linear_output); \ + return toe_linear_output; \ + } \ + T ApplyAnvilEngineToneMapShoulder(T toe_linear_output, float toe_end, float peak_ratio, float shoulder_start) { \ + float toe_to_peak_output_range = peak_ratio - toe_end; \ + float shoulder_start_output = mad(toe_to_peak_output_range, shoulder_start, toe_end); \ + return renodx::tonemap::ExponentialRollOff(toe_linear_output, shoulder_start_output, peak_ratio); \ + } \ + T ApplyAnvilEngineToneMap( \ + T input, float linear_slope, float toe_end, float toe_power, float toe_offset, float peak_ratio, float shoulder_start) { \ + return ApplyAnvilEngineToneMapShoulder( \ + EvaluateAnvilEngineToeAndLinear(input, linear_slope, toe_end, toe_power, toe_offset), toe_end, peak_ratio, shoulder_start); \ } -IMMORTALS_TONEMAP_GENERATOR(float) -IMMORTALS_TONEMAP_GENERATOR(float3) -#undef IMMORTALS_TONEMAP_GENERATOR +ANVIL_ENGINE_TONEMAP_GENERATOR(float) +ANVIL_ENGINE_TONEMAP_GENERATOR(float3) +#undef ANVIL_ENGINE_TONEMAP_GENERATOR static const float PSYCHO23_LOCAL_EPSILON = 1e-6f; static const float PSYCHO23_LOCAL_REFERENCE_SIMULTANEOUS_RANGE_LOG10 = 3.7f; @@ -260,7 +229,7 @@ float3 ApplyPsycho23SignedOpponentRetentionAndGamutCompressionLMS( float output_yf = Psycho23YfFromLMS(compressed_lms); // Test23 measures white convergence in the compression power domain. Derive - // the same progress from the actual Immortals output instead of assuming its + // the same progress from the actual Anvil Engine output instead of assuming its // shoulder follows PsychoV's analytic compression curve. float compression_power = Psycho23AutoCompressionFromCenteredReferenceRange( anchor_yf, @@ -303,6 +272,8 @@ float3 ApplyPsycho23SignedOpponentRetentionAndGamutCompressionLMS( } float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float display_peak_nits, bool hdr_enabled) { + untonemapped_ap1 /= 100.f; + // The game uses twice the SDR exposure by default when HDR is enabled. float diffuse_white_nits = (exposure / 64.f) * 203.f; float target_peak_ratio = display_peak_nits / diffuse_white_nits; @@ -313,35 +284,28 @@ float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float disp target_peak_ratio = 1.f; } #if 1 - float slope = 1.5f; + float linear_slope = 1.5f; float shoulder_start = 0.5f; - float toe_threshold = 0.05f; - float toe_slope = 1.325f; - float black_offset = 0.f; - ImmortalsToneMapConfig config = CreateImmortalsToneMapConfig( - slope, - toe_threshold, - shoulder_start, - toe_slope, - black_offset, - target_peak_ratio * 100.f); + float toe_end = 0.05f; + float toe_power = 1.325f; + float toe_offset = 0.f; float3 ap1_white_lms = renodx::color::lms::from::AP1(1.f.xxx); float3 untonemapped_lms = max(renodx::color::lms::from::AP1(untonemapped_ap1), 0.f); - float3 precompression_lms; - float3 tonemapped_lms = ApplyImmortalsToneMap(untonemapped_lms / ap1_white_lms, config, precompression_lms) * ap1_white_lms / 100.f; - precompression_lms = precompression_lms / 100.f * ap1_white_lms; + float3 toe_linear_relative_lms = EvaluateAnvilEngineToeAndLinear(untonemapped_lms / ap1_white_lms, linear_slope, toe_end, toe_power, toe_offset); + float3 tonemapped_lms = ApplyAnvilEngineToneMapShoulder(toe_linear_relative_lms, toe_end, target_peak_ratio, shoulder_start) * ap1_white_lms; + float3 toe_linear_lms = toe_linear_relative_lms * ap1_white_lms; // The curve has no isolated inflection between its convex toe and concave shoulder. // Fix the output anchor at SDR midgray and solve its input anchor from the linear section. const float output_anchor = 0.18f; - const float input_adaptive_anchor = 100.f * (toe_threshold + ((output_anchor - toe_threshold) / slope)); + const float input_adaptive_anchor = toe_end + ((output_anchor - toe_end) / linear_slope); float3 input_adaptive_anchor_lms = renodx::color::lms::from::AP1(input_adaptive_anchor.xxx); float3 output_anchor_lms = renodx::color::lms::from::AP1(output_anchor.xxx); float3 peak_white_lms = target_peak_ratio * ap1_white_lms; tonemapped_lms = ApplyPsycho23SignedOpponentRetentionAndGamutCompressionLMS( - precompression_lms, + toe_linear_lms, tonemapped_lms, input_adaptive_anchor_lms, output_anchor_lms, @@ -378,28 +342,21 @@ float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float disp target_peak_ratio = renodx::color::correct::GammaSafe(target_peak_ratio, true); } - float slope = 1.5f; + float linear_slope = 1.5f; float shoulder_start = 0.5f; - float toe_threshold = 0.05f; - float toe_slope = 1.f; - float black_offset = 0.f; + float toe_end = 0.05f; + float toe_power = 1.f; + float toe_offset = 0.f; if (!hdr_enabled) { target_peak_ratio = 1.f; } - ImmortalsToneMapConfig config = CreateImmortalsToneMapConfig( - slope, - toe_threshold, - shoulder_start, - toe_slope, - black_offset, - target_peak_ratio * 100.f); - float3 tonemapped_ap1 = ApplyImmortalsToneMap(untonemapped_ap1, config) / 100.f; + float3 tonemapped_ap1 = ApplyAnvilEngineToneMap(untonemapped_ap1, linear_slope, toe_end, toe_power, toe_offset, target_peak_ratio, shoulder_start); tonemapped_bt709 = renodx::color::bt709::from::AP1(tonemapped_ap1); const float output_anchor = 0.18f; const float input_adaptive_anchor = - 100.f * (toe_threshold + ((output_anchor - toe_threshold) / slope)); + toe_end + ((output_anchor - toe_end) / linear_slope); float3 input_adaptive_anchor_lms = renodx::color::lms::from::AP1(input_adaptive_anchor.xxx); float3 tonemapped_lms = renodx::color::lms::from::BT709(tonemapped_bt709); From dc1a74b593e29078e9bd45012184efb3307e9169 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Wed, 12 Aug 2026 07:26:31 -0400 Subject: [PATCH 07/22] feat(asscreedblackflagresynced): integrate PsychoV25 components into custom tonemap --- .../asscreedblackflagresynced/common.hlsli | 5 + .../tonemap/customtest25.hlsli | 662 ++++++++++++++++++ .../tonemap/tonemap.hlsli | 351 +++------- 3 files changed, 760 insertions(+), 258 deletions(-) create mode 100644 src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli diff --git a/src/games/asscreedblackflagresynced/common.hlsli b/src/games/asscreedblackflagresynced/common.hlsli index 2508cb25c..708b6a461 100644 --- a/src/games/asscreedblackflagresynced/common.hlsli +++ b/src/games/asscreedblackflagresynced/common.hlsli @@ -1,3 +1,6 @@ +#ifndef RENODX_GAMES_ASSCREEDBLACKFLAGRESYNCED_COMMON_HLSLI_ +#define RENODX_GAMES_ASSCREEDBLACKFLAGRESYNCED_COMMON_HLSLI_ + #include "./shared.h" float ContrastAndFlare(float x, float contrast, float contrast_highlights, float contrast_shadows, float flare, float mid_gray = 0.18f) { @@ -99,3 +102,5 @@ float3 ApplyUserGradingAP1(float3 color_ap1, float mid_gray = 0.18f) { return color_ap1; } + +#endif // RENODX_GAMES_ASSCREEDBLACKFLAGRESYNCED_COMMON_HLSLI_ diff --git a/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli b/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli new file mode 100644 index 000000000..dff083837 --- /dev/null +++ b/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli @@ -0,0 +1,662 @@ +#ifndef RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ +#define RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ + +#include "../common.hlsli" + +/* + * Copyright (C) 2026 Carlos Lopez + * SPDX-License-Identifier: MIT + */ + +namespace renodx { +namespace tonemap { +namespace psychov { + +static const float PSYCHO25_EPSILON = 1e-6f; +static const float PSYCHO25_LARGE = 1e20f; +static const float PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE = 0.9f; +static const float PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION = 0.75f; +static const float PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON = 1e-5f; +static const float PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER = 256.f; +static const float PSYCHO25_HUE_AMPLITUDE = 0.5f; +static const float PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY = 0.8f; +static const float PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION = 0.25f; +static const float PSYCHO25_SOURCE_DIRECTION_BLEND_POWER = 2.f; + +float psycho25_SignedYfFromLMS(float3 lms) { + float3 weighted_lms = renodx::color::macleod_boynton::WeighLMS(lms); + return weighted_lms.x + weighted_lms.y; +} + +float psycho25_YfFromLMS(float3 lms) { + return max(psycho25_SignedYfFromLMS(lms), PSYCHO25_EPSILON); +} + +float3 psycho25_ToAdaptiveRelativeWeightedLMS( + float3 lms_input, + float3 current_adaptive_state_lms) { + return renodx::math::DivideSafe( + renodx::color::macleod_boynton::WeighLMS(lms_input), + current_adaptive_state_lms, + 0.f.xxx); +} + +float3 psycho25_FromAdaptiveRelativeWeightedLMS( + float3 lms_weighted_relative, + float3 current_adaptive_state_lms) { + return lms_weighted_relative + * max(current_adaptive_state_lms, PSYCHO25_EPSILON.xxx); +} + +float3 psycho25_LMSFromAdaptiveMB( + float3 mb, + float3 current_adaptive_state_lms) { + float3 relative_weighted = + renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton(mb); + return renodx::color::macleod_boynton::UnweighLMS( + psycho25_FromAdaptiveRelativeWeightedLMS( + relative_weighted, + current_adaptive_state_lms)); +} + +float3 psycho25_ApplyAdaptiveMBPurity( + float3 lms_input, + float3 adaptive_neutral_lms, + float purity_delta) { + if (abs(purity_delta - 1.f) <= 1e-5f) return lms_input; + + float3 relative_weighted = psycho25_ToAdaptiveRelativeWeightedLMS( + lms_input, + adaptive_neutral_lms); + float3 mb = renodx::color::macleod_boynton::from::WeightedLMS( + relative_weighted); + float3 mb_neutral = renodx::color::macleod_boynton::from::LMS(1.f.xxx); + float2 mb_scaled_xy = lerp(mb_neutral.xy, mb.xy, purity_delta); + float3 relative_weighted_out = + renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton( + float3(mb_scaled_xy, mb.z)); + return renodx::color::macleod_boynton::UnweighLMS( + psycho25_FromAdaptiveRelativeWeightedLMS( + relative_weighted_out, + adaptive_neutral_lms)); +} + +float3x3 psycho25_WeightedLMSToRGBMatrix(int gamut_mode) { + return gamut_mode == 0 + ? renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT709_MAT + : renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT2020_MAT; +} + +float3 psycho25_TargetRGBFromLMS(float3 lms, int gamut_mode) { + return mul( + psycho25_WeightedLMSToRGBMatrix(gamut_mode), + renodx::color::macleod_boynton::WeighLMS(lms)); +} + +float psycho25_TargetLowerPlaneBoundaryFraction( + float3 candidate_target_rgb, + float3 neutral_target_rgb) { + float boundary_fraction = PSYCHO25_LARGE; + if (candidate_target_rgb.x < neutral_target_rgb.x) { + boundary_fraction = min( + boundary_fraction, + neutral_target_rgb.x + / (neutral_target_rgb.x - candidate_target_rgb.x)); + } + if (candidate_target_rgb.y < neutral_target_rgb.y) { + boundary_fraction = min( + boundary_fraction, + neutral_target_rgb.y + / (neutral_target_rgb.y - candidate_target_rgb.y)); + } + if (candidate_target_rgb.z < neutral_target_rgb.z) { + boundary_fraction = min( + boundary_fraction, + neutral_target_rgb.z + / (neutral_target_rgb.z - candidate_target_rgb.z)); + } + return boundary_fraction; +} + +float psycho25_CompressTargetLowerPlaneRadius(float boundary_fraction) { + float knee = PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE * boundary_fraction; + float headroom = boundary_fraction - knee; + float excess = max(1.f - knee, 0.f); + return 1.f - excess + + renodx::math::DivideSafe( + headroom * excess, + headroom + excess, + 0.f); +} + +float psycho25_SmoothPositive(float value) { + float smooth_length = sqrt( + value * value + + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON + * PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); + float normalized_value = value / smooth_length; + return 0.5f * value * normalized_value * (1.f + normalized_value); +} + +float psycho25_IntersectTargetPlaneSupports(float a, float b) { + float normalization = max(a, b); + float normalized_a = a / normalization; + float normalized_b = b / normalization; + float denominator = normalization + * pow( + pow(normalized_a, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER) + + pow(normalized_b, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER), + rcp(PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER)); + return a * b / denominator; +} + +float psycho25_IntersectTargetPlaneSupports(float3 support) { + return psycho25_IntersectTargetPlaneSupports( + support.x, + psycho25_IntersectTargetPlaneSupports(support.y, support.z)); +} + +float psycho25_TargetLowerPlaneRadiusForDirection( + float2 direction, + float2 adapted_neutral_mb, + float3 current_adaptive_state_lms, + int target_gamut_mode) { + float3 neutral_lms = psycho25_LMSFromAdaptiveMB( + float3(adapted_neutral_mb, 1.f), + current_adaptive_state_lms); + float3 unit_radius_lms = psycho25_LMSFromAdaptiveMB( + float3(adapted_neutral_mb + direction, 1.f), + current_adaptive_state_lms); + float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( + neutral_lms, + target_gamut_mode); + float3 direction_target_rgb = psycho25_TargetRGBFromLMS( + unit_radius_lms - neutral_lms, + target_gamut_mode); + float3 lower_support = neutral_target_rgb + / (float3( + psycho25_SmoothPositive(-direction_target_rgb.x), + psycho25_SmoothPositive(-direction_target_rgb.y), + psycho25_SmoothPositive(-direction_target_rgb.z)) + + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); + return psycho25_IntersectTargetPlaneSupports(lower_support); +} + +} // namespace psychov +} // namespace tonemap +} // namespace renodx + +float3 ComputeCInfinityTransition(float3 position) { + position = saturate(position); + return 1.f / (1.f + exp2((1.f - 2.f * position) / (position * (1.f - position)))); +} + +// Monotonic and C-infinity continuous anchored tonal grading +float3 ApplyAnchoredTonalGrading( + float3 color, + float3 anchor_in = 0.18f, + float3 anchor_out = 0.18f, + float contrast = 1.f, + float flare = 0.f, + float highlight_contrast = 1.f, + float shadow_contrast = 1.f, + float highlights = 1.f, + float shadows = 1.f) { + [branch] + if (contrast == 1.f + && flare == 0.f + && highlight_contrast == 1.f + && shadow_contrast == 1.f + && highlights == 1.f + && shadows == 1.f + && all(anchor_in == anchor_out)) { + return color; + } + + float3 ax = abs(color); + float3 normalized = ax / anchor_in; + float3 contrasted_normalized = normalized; + + // Power contrast and shadow flare, optionally bounding contrast on highlights. + [branch] + if (contrast != 1.f || flare > 0.f) { + float3 exponent = contrast; + + [branch] + if (flare > 0.f) { + float3 shadow_distance = saturate(1.f - normalized); + float3 flat_shadow_weight = exp2(-normalized / shadow_distance); + exponent *= mad(flat_shadow_weight, flare / (normalized + flare), 1.f); + } + +#if 1 + float3 input_stops = log2(normalized); + float3 highlight_stops = max(input_stops, 0.f); + float3 output_highlight_stops = highlight_stops; + + [branch] + if (contrast != 1.f) { + float3 contrast_displacement = (contrast - 1.f) * highlight_stops; + float3 displacement_magnitude = abs(contrast_displacement); + output_highlight_stops += contrast_displacement / mad(displacement_magnitude, exp2(-1.f / displacement_magnitude), 1.f); + } + + contrasted_normalized = exp2(mad(exponent, min(input_stops, 0.f), output_highlight_stops)); +#else + contrasted_normalized = pow(normalized, exponent); +#endif + } + + // broad highlight contrast. + [branch] + if (highlight_contrast != 1.f) { + float3 highlight_distance = max(contrasted_normalized - 1.f, 0.f); + float3 highlight_distance_squared = highlight_distance * highlight_distance; + float3 flat_highlight_distance = (1.f + highlight_distance_squared) * exp2(-1.f / highlight_distance_squared); + contrasted_normalized += highlight_distance * (pow(1.f + flat_highlight_distance, 0.5f * (highlight_contrast - 1.f)) - 1.f); + } + + // broad shadow contrast. + [branch] + if (shadow_contrast != 1.f) { + float3 shadow_distance = saturate(1.f - contrasted_normalized); + float3 shadow_distance_squared = shadow_distance * shadow_distance; + float3 flat_shadow_distance = shadow_distance_squared * shadow_distance * exp2(1.f - 1.f / shadow_distance_squared); + contrasted_normalized *= pow(1.f + flat_shadow_distance, shadow_contrast - 1.f); + } + + // Mirror offsets about the anchor over the declared stop range. + [branch] + if (highlights != 1.f || shadows != 1.f) { + static const float TONAL_OFFSET_START_STOPS = 1.f; + static const float TONAL_OFFSET_END_STOPS = 8.f; + static const float TONAL_OFFSET_INVERSE_RANGE_STOPS = 1.f / (TONAL_OFFSET_END_STOPS - TONAL_OFFSET_START_STOPS); + + float3 tonal_stops = log2(contrasted_normalized); + float3 tonal_displacement = 0.f; + + [branch] + if (highlights != 1.f) { + float highlight_adjustment = highlights - 1.f; + float highlight_displacement = highlight_adjustment * mad(1.5f, abs(highlight_adjustment), 0.5f); + float3 highlight_weight = ComputeCInfinityTransition((tonal_stops - TONAL_OFFSET_START_STOPS) * TONAL_OFFSET_INVERSE_RANGE_STOPS); + tonal_displacement = mad(highlight_displacement, highlight_weight, tonal_displacement); + } + + [branch] + if (shadows != 1.f) { + float shadow_adjustment = shadows - 1.f; + float shadow_displacement = shadow_adjustment * mad(1.5f, abs(shadow_adjustment), 0.5f); + float3 shadow_weight = ComputeCInfinityTransition((-TONAL_OFFSET_START_STOPS - tonal_stops) * TONAL_OFFSET_INVERSE_RANGE_STOPS); + tonal_displacement = mad(shadow_displacement, shadow_weight, tonal_displacement); + } + + contrasted_normalized *= exp2(tonal_displacement); + } + + return renodx::math::CopySign(contrasted_normalized * anchor_out, color); +} + +/// Identity through anchor to every derivative; then approaches peak +/// monotonically and concave down. Requires anchor < peak and compression_strength >= 1. +#define APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(T) \ + T ApplyAnchoredCInfinityShoulder(T color, T peak, T anchor, float compression_strength) { \ + T shoulder_range = peak - anchor; \ + T distance_from_anchor = max(color - anchor, (T)0.f); \ + T flat_weight = exp2(-shoulder_range / (compression_strength * distance_from_anchor)); \ + T response_denominator = mad(distance_from_anchor, flat_weight, shoulder_range); \ + return mad(shoulder_range, distance_from_anchor / response_denominator, color - distance_from_anchor); \ + } + +APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float) +APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float3) +#undef APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR + +float ApplyAnchoredCInfinityShoulderMaxChannelScale(float3 color, float peak, float anchor, float compression_strength) { + float max_channel = renodx::math::Max(abs(color)); + float compressed_max = ApplyAnchoredCInfinityShoulder(max_channel, peak, anchor, compression_strength); + return renodx::math::DivideSafe(compressed_max, max_channel, 1.f); +} + +/// Identity through anchor; then approaches peak monotonically and concave down. +/// The anchor join is C2 continuous. Requires anchor < peak and compression_strength >= 1. +#define APPLYANCHOREDCUBICSHOULDER_GENERATOR(T) \ + T ApplyAnchoredCubicShoulder(T color, T peak, T anchor, float compression_strength) { \ + T shoulder_range = peak - anchor; \ + T distance_from_anchor = max(color - anchor, (T)0.f); \ + T weighted_distance = compression_strength * distance_from_anchor; \ + T response_numerator = distance_from_anchor * (shoulder_range + weighted_distance); \ + T response_denominator = mad( \ + shoulder_range, shoulder_range, weighted_distance * (shoulder_range + distance_from_anchor)); \ + return mad(shoulder_range, response_numerator / response_denominator, color - distance_from_anchor); \ + } + +/// Identity through anchor; reaches peak at clip, then remains flat. +/// Monotonic, concave down, and C2 when clip meets the calculated minimum. +#define APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR(T) \ + T ApplyAnchoredCubicShoulder( \ + T color, T peak, T anchor, float compression_strength, T clip) { \ + T shoulder_range = peak - anchor; \ + T distance_from_anchor = max(color - anchor, (T)0.f); \ + T input_range = clip - anchor; \ + T clipped_distance = min(distance_from_anchor, input_range); \ + T clip_position = clipped_distance / input_range; \ + T clip_position_squared = clip_position * clip_position; \ + T clip_position_cubed = clip_position_squared * clip_position; \ + T residual_weight = (T)1.f - clip_position_cubed * mad(clip_position, mad((T)6.f, clip_position, (T) - 15.f), (T)10.f); \ + T weighted_distance = compression_strength * clipped_distance; \ + T response_numerator = clipped_distance * (shoulder_range + weighted_distance); \ + T remaining_distance = shoulder_range * mad(compression_strength - 1.f, clipped_distance, shoulder_range); \ + T response_denominator = mad(residual_weight, remaining_distance, response_numerator); \ + return mad(shoulder_range, response_numerator / response_denominator, color - distance_from_anchor); \ + } + +APPLYANCHOREDCUBICSHOULDER_GENERATOR(float) +APPLYANCHOREDCUBICSHOULDER_GENERATOR(float3) +APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR(float) +APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR(float3) +#undef APPLYANCHOREDCUBICSHOULDER_GENERATOR +#undef APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR + +// Fixed PsychoV25 target-hull path: Fast60 hue guidance, Reference Scale, +// full BT.2020 lower/upper-plane enforcement, and a black upper-hull pivot. +float3 CompressPsychoV25ReferenceScaleHull( + float3 desired_lms, + float3 direction_source_lms, + float3 adaptive_state_lms, + float3 background_state_lms, + float3 target_lms_peak, + float source_direction_recovery_strength, + float post_saturation, + float compression, + float peak_value) { + float3 desired_weighted_lms = renodx::color::macleod_boynton::WeighLMS(desired_lms); + float desired_yf = desired_weighted_lms.x + desired_weighted_lms.y; + if (desired_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { + return 0.f.xxx; + } + + float adaptive_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(adaptive_state_lms); + float background_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(background_state_lms); + float target_peak_yf = renodx::tonemap::psychov::psycho25_SignedYfFromLMS(target_lms_peak); + float3 physical_compressed_lms = ApplyAnchoredCInfinityShoulder( + desired_lms, + target_lms_peak, + background_state_lms, + compression); + float authored_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(physical_compressed_lms); + if (authored_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { + return 0.f.xxx; + } + + float3 safe_adaptive_state_lms = max( + adaptive_state_lms, + renodx::tonemap::psychov::PSYCHO25_EPSILON.xxx); + float2 adapted_neutral_mb = renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; + float3 authored_mb = renodx::color::macleod_boynton::from::WeightedLMS( + renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( + physical_compressed_lms, + adaptive_state_lms)); + float3 source_mb = renodx::color::macleod_boynton::from::WeightedLMS( + renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( + direction_source_lms, + adaptive_state_lms)); + + // Fast60: retain physical radius and use the angular midpoint between the + // source direction and the raw per-cone-compressed direction. + float2 authored_offset = authored_mb.xy - adapted_neutral_mb; + float2 source_offset = source_mb.xy - adapted_neutral_mb; + float authored_radius2 = dot(authored_offset, authored_offset); + float source_radius2 = dot(source_offset, source_offset); + if (authored_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON + && source_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON) { + float2 source_direction = source_offset * rsqrt(source_radius2); + float2 compressed_direction = authored_offset * rsqrt(authored_radius2); + float2 output_direction = lerp( + source_direction, + compressed_direction, + 1.f - renodx::tonemap::psychov::PSYCHO25_HUE_AMPLITUDE); + float output_direction2 = dot(output_direction, output_direction); + if (output_direction2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON) { + authored_mb.xy = adapted_neutral_mb + + output_direction * rsqrt(output_direction2) * sqrt(authored_radius2); + authored_offset = authored_mb.xy - adapted_neutral_mb; + authored_radius2 = dot(authored_offset, authored_offset); + } + } + + float authored_radius = sqrt(authored_radius2); + float2 authored_direction = authored_offset * rsqrt(authored_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); + + // Reference Scale source-direction recovery keeps collapsing saturated + // highlights from rotating through an unrelated hue on their way to white. + [branch] + if (source_direction_recovery_strength > 0.f) { + float source_radius = sqrt(source_radius2); + float2 source_direction = source_offset * rsqrt(source_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); + float source_radius_support = + renodx::tonemap::psychov::psycho25_TargetLowerPlaneRadiusForDirection( + source_direction, + adapted_neutral_mb, + adaptive_state_lms, + 1); + float source_direction_support_radius = + renodx::tonemap::psychov::PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY + * source_radius_support + * renodx::math::DivideSafe( + source_radius, + sqrt(source_radius2 + source_radius_support * source_radius_support), + 0.f); + float radius_normalization = max( + max(authored_radius, source_direction_support_radius), + renodx::tonemap::psychov::PSYCHO25_EPSILON); + float authored_weight = pow( + authored_radius / radius_normalization, + renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); + float source_direction_support_weight = pow( + source_direction_support_radius / radius_normalization, + renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); + float source_hue_support = + renodx::tonemap::psychov::PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION + * source_radius_support; + float source_hue_confidence = renodx::math::DivideSafe( + source_radius2, + source_radius2 + source_hue_support * source_hue_support, + 0.f); + float source_collapse_weight = renodx::math::DivideSafe( + source_direction_support_weight, + authored_weight + source_direction_support_weight, + 0.f); + float source_direction_weight = source_direction_recovery_strength + * (1.f - (1.f - source_hue_confidence) * (1.f - source_collapse_weight)); + float2 combined_direction = lerp( + authored_direction, + source_direction, + source_direction_weight); + combined_direction *= rsqrt( + dot(combined_direction, combined_direction) + + renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON); + authored_direction = combined_direction; + authored_offset = authored_direction * authored_radius; + authored_mb.xy = adapted_neutral_mb + authored_offset; + } + + // Adjust saturation only after Fast60 and source-direction recovery have + // authored the hue, but before Reference Scale gamut containment. + [branch] + if (post_saturation != 1.f) { + float saturation_scale = max(post_saturation, 0.f); + authored_radius *= saturation_scale; + authored_offset = authored_direction * authored_radius; + authored_mb.xy = adapted_neutral_mb + authored_offset; + source_offset *= saturation_scale; + source_mb.xy = adapted_neutral_mb + source_offset; + } + + // Discard the trajectory's carried scale, preserving only its authored + // adaptive-MB direction and radius before solving the BT.2020 hull. + float trajectory_yf_for_normalization = authored_mb.z + * (authored_mb.x * safe_adaptive_state_lms.x + + (1.f - authored_mb.x) * safe_adaptive_state_lms.y); + float3 unit_yf_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( + float3( + authored_mb.xy, + renodx::math::DivideSafe( + authored_mb.z, + trajectory_yf_for_normalization, + 0.f)), + adaptive_state_lms); + float3 neutral_lms = adaptive_state_lms / adaptive_yf; + + // Reference Scale lower-plane compression keeps the authored hue ray inside + // the nonnegative BT.2020 primary half-spaces without a component clamp. + if (authored_radius > renodx::tonemap::psychov::PSYCHO25_EPSILON) { + float3 neutral_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(neutral_lms, 1); + float3 current_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); + float current_boundary_fraction = + renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( + current_target_rgb, + neutral_target_rgb); + float current_radius_scale = + renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( + current_boundary_fraction); + + authored_direction = authored_offset / authored_radius; + float containment_reference_radius = max( + authored_radius, + length(source_mb.xy - adapted_neutral_mb)); + float3 reference_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( + float3( + adapted_neutral_mb + + authored_direction * containment_reference_radius, + 1.f), + adaptive_state_lms); + reference_lms /= renodx::tonemap::psychov::psycho25_YfFromLMS(reference_lms); + float3 reference_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(reference_lms, 1); + float reference_boundary_fraction = + renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( + reference_target_rgb, + neutral_target_rgb); + float reference_radius_scale = + renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( + reference_boundary_fraction); + + float trajectory_fraction = authored_radius / containment_reference_radius; + float release_progress = saturate( + trajectory_fraction + / renodx::tonemap::psychov::PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION); + float neutral_scale = min(1.f, 4.f * reference_radius_scale); + float release_weight = 1.f - release_progress; + float radius_scale = min( + lerp( + reference_radius_scale, + neutral_scale, + release_weight * release_weight), + current_radius_scale); + unit_yf_lms = lerp(neutral_lms, unit_yf_lms, radius_scale); + } + + // Black-pivot upper-plane shoulder along the contained BT.2020 hue ray. + float3 unit_target_rgb = + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); + float max_target_channel = max( + unit_target_rgb.x, + max(unit_target_rgb.y, unit_target_rgb.z)); + float directional_yf_limit = peak_value / max_target_channel; + float normalized_input = desired_yf * renodx::math::DivideSafe(target_peak_yf, directional_yf_limit, 1.f); + float normalized_output = ApplyAnchoredCInfinityShoulder( + normalized_input, + target_peak_yf, + background_yf, + compression); + float output_yf = normalized_output * renodx::math::DivideSafe(directional_yf_limit, target_peak_yf, 1.f); + return unit_yf_lms * output_yf; +} + +float3 ApplyCustomPsychoV25ToneMap( + float3 bt709_linear_input, + float peak_value, + float highlights, + float shadows, + float cone_response_exponent, + float flare, + float purity_scale, + float highlight_saturation, + float dechroma, + float source_direction_recovery_strength = 0.f, + float3 current_adaptive_state_bt709 = 0.18f, + float3 current_background_state_bt709 = 0.18f, + float compression = 1.5f) { + float3 finite_bt709_input = renodx::math::ZeroNaN(bt709_linear_input); + finite_bt709_input = renodx::math::Select( + isinf(finite_bt709_input), + renodx::math::CopySign(65504.f.xxx, finite_bt709_input), + finite_bt709_input); + + float3 lms_in = renodx::color::lms::from::BT709(finite_bt709_input); + float3 current_adaptive_state_lms = renodx::color::lms::from::BT709(current_adaptive_state_bt709); + float3 current_background_state_lms = renodx::color::lms::from::BT709(current_background_state_bt709); + float3 target_lms_peak = renodx::color::lms::from::BT709(peak_value.xxx); + + if (dechroma != 0.f || highlight_saturation != 1.f) { + float luminance = renodx::color::yf::from::LMS(lms_in); + float neutral_luminance = renodx::color::yf::from::LMS(current_adaptive_state_lms); + + // Ramp purity grading over 2.75 decades above the adaptive neutral. + static const float INVERSE_HIGHLIGHT_RANGE_STOPS = 1.f / (2.75f * log2(10.f)); + static const float HIGHLIGHT_ROLLOFF_CUBIC_BLEND = 0.5f; + static const float HIGHLIGHT_PURITY_STRENGTH = 2.f / 3.f; + + float luminance_from_neutral = max(luminance, neutral_luminance) / neutral_luminance; + float rolloff_position = saturate(log2(luminance_from_neutral) * INVERSE_HIGHLIGHT_RANGE_STOPS); + float rolloff_position_squared = rolloff_position * rolloff_position; + float rolloff = rolloff_position_squared * rolloff_position * mad(rolloff_position, mad(6.f, rolloff_position, -15.f), 10.f); + + // Base smootherstep brings dechroma into the midtones while remaining monotonic and C2. + if (dechroma != 0.f) { + purity_scale *= mad(-dechroma, rolloff, 1.f); + } + + // Blend smootherstep squared and cubed for a later, gentler C2 progression. + if (highlight_saturation != 1.f) { + float highlight_rolloff = rolloff * rolloff * mad(HIGHLIGHT_ROLLOFF_CUBIC_BLEND, rolloff, 1.f - HIGHLIGHT_ROLLOFF_CUBIC_BLEND); + purity_scale *= mad(highlight_saturation - 1.f, highlight_rolloff * HIGHLIGHT_PURITY_STRENGTH, 1.f); + } + } + + float3 contrast_input = renodx::tonemap::psychov::psycho25_ApplyAdaptiveMBPurity( + lms_in, + current_adaptive_state_lms, + purity_scale); + float3 contrast_lms = ApplyAnchoredTonalGrading( + contrast_input, + current_adaptive_state_lms, + current_background_state_lms, + cone_response_exponent, + flare, + 1.f, + 1.f, + highlights, + shadows); + + float3 output_lms = CompressPsychoV25ReferenceScaleHull( + contrast_lms, + contrast_input, + current_adaptive_state_lms, + current_background_state_lms, + target_lms_peak, + source_direction_recovery_strength, + 1.f, + compression, + peak_value); + return renodx::color::bt709::from::LMS(output_lms); +} + +#endif // RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ \ No newline at end of file diff --git a/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli b/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli index d39ba051a..def6586c0 100644 --- a/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli +++ b/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli @@ -1,4 +1,5 @@ #include "../common.hlsli" +#include "./customtest25.hlsli" #define ANVIL_ENGINE_TONEMAP_GENERATOR(T) \ T EvaluateAnvilEngineToeAndLinear(T input, float linear_slope, float toe_end, float toe_power, float toe_offset) { \ @@ -30,39 +31,85 @@ ANVIL_ENGINE_TONEMAP_GENERATOR(float) ANVIL_ENGINE_TONEMAP_GENERATOR(float3) #undef ANVIL_ENGINE_TONEMAP_GENERATOR -static const float PSYCHO23_LOCAL_EPSILON = 1e-6f; -static const float PSYCHO23_LOCAL_REFERENCE_SIMULTANEOUS_RANGE_LOG10 = 3.7f; -static const float PSYCHO23_LOCAL_REFERENCE_CENTERED_RANGE_SIDE_COUNT = 2.f; -static const float PSYCHO23_LOCAL_HEADROOM_RATIO_FALLBACK = 1.f; -static const float PSYCHO23_LOCAL_MIN_AUTO_COMPRESSION = 1.f; +#define CUSTOM_ANVIL_ENGINE_TONEMAP_GENERATOR(T) \ + T EvaluateCustomAnvilEngineToeAndLinear(T input, float linear_slope, float toe_end, float toe_power, float toe_offset, float toe_flare) { \ + T linear_output = mad(input - toe_end, linear_slope, toe_end); \ + if (toe_end <= 1e-5f) return linear_output; \ + T toe_progress = saturate(input / toe_end); \ + T effective_toe_power = toe_power; \ + [branch] \ + if (toe_flare > 0.f) { \ + T shadow_distance = 1.f - toe_progress; \ + T flat_shadow_weight = exp2(-toe_progress / shadow_distance); \ + effective_toe_power *= mad(flat_shadow_weight, toe_flare / (toe_progress + toe_flare), 1.f); \ + } \ + T toe_output = mad(pow(toe_progress, effective_toe_power), toe_end, toe_offset); \ + T toe_to_linear_blend = rcp(1.f + exp2((1.f - 2.f * toe_progress) / (toe_progress * (1.f - toe_progress)))); \ + return mad(toe_to_linear_blend, linear_output - toe_output, toe_output); \ + } -// Empirical signed-opponent appearance controls from PsychoV23. -static const float PSYCHO23_LOCAL_RED_RETENTION = 1.5f; -static const float PSYCHO23_LOCAL_GREEN_RETENTION = 2.f; -static const float PSYCHO23_LOCAL_BLUE_RETENTION = 1.f; -static const float PSYCHO23_LOCAL_YELLOW_RETENTION = 3.f; +CUSTOM_ANVIL_ENGINE_TONEMAP_GENERATOR(float) +CUSTOM_ANVIL_ENGINE_TONEMAP_GENERATOR(float3) +#undef CUSTOM_ANVIL_ENGINE_TONEMAP_GENERATOR -float Psycho23YfFromLMS(float3 lms) { - float3 weighted_lms = renodx::color::macleod_boynton::WeighLMS(lms); - return max(weighted_lms.x + weighted_lms.y, PSYCHO23_LOCAL_EPSILON); +float3 CompressAnvilEnginePsychoV25ReferenceScaleHull( + float3 desired_lms, + float3 direction_source_lms, + float3 adaptive_state_lms, + float3 target_lms_peak, + float shoulder_start_output, + float source_direction_recovery_strength, + float post_saturation, + float compression, + float peak_value) { + return CompressPsychoV25ReferenceScaleHull( + desired_lms, + direction_source_lms, + adaptive_state_lms, + renodx::color::lms::from::AP1(shoulder_start_output.xxx), + target_lms_peak, + source_direction_recovery_strength, + post_saturation, + compression, + peak_value); } -float Psycho23AutoCompressionFromCenteredReferenceRange(float anchor_out_yf, float peak_yf) { - float peak_over_anchor = renodx::math::DivideSafe( - max(peak_yf, PSYCHO23_LOCAL_EPSILON), - max(anchor_out_yf, PSYCHO23_LOCAL_EPSILON), - PSYCHO23_LOCAL_HEADROOM_RATIO_FALLBACK); - peak_over_anchor = max(peak_over_anchor, 1.f + PSYCHO23_LOCAL_EPSILON); - - float reference_one_side_range_log10 = - PSYCHO23_LOCAL_REFERENCE_SIMULTANEOUS_RANGE_LOG10 - / PSYCHO23_LOCAL_REFERENCE_CENTERED_RANGE_SIDE_COUNT; - float actual_above_adaptation_range_log10 = - max(log10(peak_over_anchor), PSYCHO23_LOCAL_EPSILON); - - return max( - reference_one_side_range_log10 / actual_above_adaptation_range_log10, - PSYCHO23_LOCAL_MIN_AUTO_COMPRESSION); +float3 ApplyCustomAnvilEnginePsychoV25ToneMap( + float3 untonemapped_ap1, + float peak_value, + float linear_slope, + float toe_end, + float toe_power, + float toe_offset, + float toe_flare, + float post_saturation, + float shoulder_start, + float source_direction_recovery_strength = 0.f, + float compression = 1.f) { + float3 white_lms = renodx::color::lms::from::AP1(1.f.xxx); + float3 untonemapped_lms = max(renodx::color::lms::from::AP1(untonemapped_ap1), 0.f); + + // The curve has no isolated inflection between its convex toe and concave shoulder. + // Anchor adaptation at the input that the linear section maps to SDR midgray, + // independently of the supplied C-infinity shoulder start. + static const float OUTPUT_ANCHOR = 0.18f; + float input_adaptive_anchor = toe_end + ((OUTPUT_ANCHOR - toe_end) / linear_slope); + float3 input_adaptive_anchor_lms = input_adaptive_anchor * white_lms; + float3 toe_linear_lms = EvaluateCustomAnvilEngineToeAndLinear(untonemapped_lms / white_lms, linear_slope, toe_end, toe_power, toe_offset, toe_flare) * white_lms; + float3 peak_white_lms = peak_value * white_lms; + float toe_to_peak_output_range = peak_value - toe_end; + float shoulder_start_output = mad(toe_to_peak_output_range, shoulder_start, toe_end); + + return renodx::color::ap1::from::LMS(CompressAnvilEnginePsychoV25ReferenceScaleHull( + toe_linear_lms, + untonemapped_lms, + input_adaptive_anchor_lms, + peak_white_lms, + shoulder_start_output, + source_direction_recovery_strength, + post_saturation, + compression, + peak_value)); } float3 Psycho23ToAdaptiveRelativeWeightedLMS( @@ -92,185 +139,6 @@ float3 Psycho23GamutCompressAdaptiveRelativeWeightedLMSBound( strength); } -float3 Psycho23AdaptiveRelativeWeightedNeutral() { - return renodx::color::macleod_boynton::WeighLMS(1.f.xxx); -} - -float3 Psycho23OpponentACCFromWeightedDelta(float3 delta_weighted_lms) { - float3 neutral_weighted = Psycho23AdaptiveRelativeWeightedNeutral(); - float m_to_l = renodx::math::DivideSafe( - neutral_weighted.x, - neutral_weighted.y, - 0.f); - float s_to_lm = renodx::math::DivideSafe( - neutral_weighted.x + neutral_weighted.y, - neutral_weighted.z, - 0.f); - - return float3( - delta_weighted_lms.x + delta_weighted_lms.y, - delta_weighted_lms.x - m_to_l * delta_weighted_lms.y, - -delta_weighted_lms.x - delta_weighted_lms.y - + s_to_lm * delta_weighted_lms.z); -} - -float3 Psycho23WeightedDeltaFromOpponentACC(float3 acc) { - float3 neutral_weighted = Psycho23AdaptiveRelativeWeightedNeutral(); - float m_to_l = renodx::math::DivideSafe( - neutral_weighted.x, - neutral_weighted.y, - 0.f); - float s_to_lm = renodx::math::DivideSafe( - neutral_weighted.x + neutral_weighted.y, - neutral_weighted.z, - 0.f); - - float delta_m = renodx::math::DivideSafe(acc.x - acc.y, 1.f + m_to_l, 0.f); - float delta_l = acc.x - delta_m; - float delta_s = renodx::math::DivideSafe(acc.z + acc.x, s_to_lm, 0.f); - return float3(delta_l, delta_m, delta_s); -} - -float Psycho23SignedOpponentRetention(float white_progress, float retention_exponent) { - return 1.f - pow(saturate(white_progress), max(retention_exponent, PSYCHO23_LOCAL_EPSILON)); -} - -float3 Psycho23ApplySignedOpponentRetention( - float3 compressed_lms, - float3 source_lms, - float3 adaptive_state_lms, - float3 peak_lms, - float white_progress) { - if (white_progress <= 0.f - || min(source_lms.x, min(source_lms.y, source_lms.z)) <= 0.f) { - return compressed_lms; - } - - float3 source_weighted = Psycho23ToAdaptiveRelativeWeightedLMS( - source_lms, - adaptive_state_lms); - float3 adapted_neutral = Psycho23AdaptiveRelativeWeightedNeutral(); - float adapted_neutral_yf = adapted_neutral.x + adapted_neutral.y; - float source_yf = source_weighted.x + source_weighted.y; - - if (source_yf <= PSYCHO23_LOCAL_EPSILON - || adapted_neutral_yf <= PSYCHO23_LOCAL_EPSILON) { - return compressed_lms; - } - - float3 source_neutral = adapted_neutral - * renodx::math::DivideSafe(source_yf, adapted_neutral_yf, 1.f); - float3 source_acc = - Psycho23OpponentACCFromWeightedDelta(source_weighted - source_neutral) - / source_yf; - - float red_retention = Psycho23SignedOpponentRetention( - white_progress, - PSYCHO23_LOCAL_RED_RETENTION); - float green_retention = Psycho23SignedOpponentRetention( - white_progress, - PSYCHO23_LOCAL_GREEN_RETENTION); - float blue_retention = Psycho23SignedOpponentRetention( - white_progress, - PSYCHO23_LOCAL_BLUE_RETENTION); - float yellow_retention = Psycho23SignedOpponentRetention( - white_progress, - PSYCHO23_LOCAL_YELLOW_RETENTION); - - float rg_out = max(source_acc.y, 0.f) * red_retention - - max(-source_acc.y, 0.f) * green_retention; - float yv_out = max(source_acc.z, 0.f) * blue_retention - - max(-source_acc.z, 0.f) * yellow_retention; - - float3 compressed_weighted = Psycho23ToAdaptiveRelativeWeightedLMS( - compressed_lms, - adaptive_state_lms); - float target_yf = compressed_weighted.x + compressed_weighted.y; - if (target_yf <= PSYCHO23_LOCAL_EPSILON) { - return compressed_lms; - } - - float3 peak_weighted = Psycho23ToAdaptiveRelativeWeightedLMS( - peak_lms, - adaptive_state_lms); - float peak_weighted_yf = peak_weighted.x + peak_weighted.y; - if (peak_weighted_yf <= PSYCHO23_LOCAL_EPSILON) { - return compressed_lms; - } - - float3 target_neutral = peak_weighted * renodx::math::DivideSafe(target_yf, peak_weighted_yf, 1.f); - float3 target_delta = Psycho23WeightedDeltaFromOpponentACC( - float3(0.f, rg_out * target_yf, yv_out * target_yf)); - float3 output_lms = renodx::color::macleod_boynton::UnweighLMS( - Psycho23FromAdaptiveRelativeWeightedLMS( - target_neutral + target_delta, - adaptive_state_lms)); - - float compressed_yf = Psycho23YfFromLMS(compressed_lms); - float output_yf = Psycho23YfFromLMS(output_lms); - if (output_yf <= PSYCHO23_LOCAL_EPSILON) { - return compressed_lms; - } - - return output_lms * renodx::math::DivideSafe(compressed_yf, output_yf, 1.f); -} - -float3 ApplyPsycho23SignedOpponentRetentionAndGamutCompressionLMS( - float3 precompression_lms, - float3 compressed_lms, - float3 input_adaptive_state_lms, - float3 output_anchor_lms, - float3 peak_white_lms, - float3x3 gamut_bound_rgb_to_lms_weighted_mat, - float hue_restore = 1.f, - float gamut_compression = 1.f) { - float anchor_yf = Psycho23YfFromLMS(output_anchor_lms); - float peak_yf = Psycho23YfFromLMS(peak_white_lms); - float output_yf = Psycho23YfFromLMS(compressed_lms); - - // Test23 measures white convergence in the compression power domain. Derive - // the same progress from the actual Anvil Engine output instead of assuming its - // shoulder follows PsychoV's analytic compression curve. - float compression_power = Psycho23AutoCompressionFromCenteredReferenceRange( - anchor_yf, - peak_yf); - float anchor_over_peak = saturate(renodx::math::DivideSafe(anchor_yf, peak_yf, 1.f)); - float output_over_peak = max(renodx::math::DivideSafe(output_yf, peak_yf, 0.f), 0.f); - float anchor_powered = pow(max(anchor_over_peak, 1e-6f), compression_power); - float white_progress = saturate(renodx::math::DivideSafe( - pow(output_over_peak, compression_power) - anchor_powered, - 1.f - anchor_powered, - 0.f)); - - float3 opponent_retained_lms = Psycho23ApplySignedOpponentRetention( - compressed_lms, - precompression_lms, - input_adaptive_state_lms, - peak_white_lms, - white_progress); - float3 hue_restored_lms = lerp( - compressed_lms, - opponent_retained_lms, - saturate(hue_restore)); - - float3 display_relative_weighted = Psycho23ToAdaptiveRelativeWeightedLMS( - hue_restored_lms, - input_adaptive_state_lms); - - if (gamut_compression != 0.f) { - display_relative_weighted = Psycho23GamutCompressAdaptiveRelativeWeightedLMSBound( - display_relative_weighted, - input_adaptive_state_lms, - gamut_bound_rgb_to_lms_weighted_mat, - gamut_compression); - } - - return renodx::color::macleod_boynton::UnweighLMS( - Psycho23FromAdaptiveRelativeWeightedLMS( - display_relative_weighted, - input_adaptive_state_lms)); -} - float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float display_peak_nits, bool hdr_enabled) { untonemapped_ap1 /= 100.f; @@ -283,60 +151,27 @@ float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float disp if (!hdr_enabled) { target_peak_ratio = 1.f; } -#if 1 - float linear_slope = 1.5f; - float shoulder_start = 0.5f; + + float linear_slope = 1.563f; + float shoulder_start = 0.48f; float toe_end = 0.05f; - float toe_power = 1.325f; + float toe_power = 1.31f; float toe_offset = 0.f; + float toe_flare = 0.1f * pow(0.8f, 10.f); + float post_saturation = 1.f; - float3 ap1_white_lms = renodx::color::lms::from::AP1(1.f.xxx); - float3 untonemapped_lms = max(renodx::color::lms::from::AP1(untonemapped_ap1), 0.f); - float3 toe_linear_relative_lms = EvaluateAnvilEngineToeAndLinear(untonemapped_lms / ap1_white_lms, linear_slope, toe_end, toe_power, toe_offset); - float3 tonemapped_lms = ApplyAnvilEngineToneMapShoulder(toe_linear_relative_lms, toe_end, target_peak_ratio, shoulder_start) * ap1_white_lms; - float3 toe_linear_lms = toe_linear_relative_lms * ap1_white_lms; - - // The curve has no isolated inflection between its convex toe and concave shoulder. - // Fix the output anchor at SDR midgray and solve its input anchor from the linear section. - const float output_anchor = 0.18f; - const float input_adaptive_anchor = toe_end + ((output_anchor - toe_end) / linear_slope); - float3 input_adaptive_anchor_lms = renodx::color::lms::from::AP1(input_adaptive_anchor.xxx); - float3 output_anchor_lms = renodx::color::lms::from::AP1(output_anchor.xxx); - float3 peak_white_lms = target_peak_ratio * ap1_white_lms; - - tonemapped_lms = ApplyPsycho23SignedOpponentRetentionAndGamutCompressionLMS( - toe_linear_lms, - tonemapped_lms, - input_adaptive_anchor_lms, - output_anchor_lms, - peak_white_lms, - hdr_enabled ? renodx::color::macleod_boynton::BT2020_TO_LMS_WEIGHTED_MAT - : renodx::color::macleod_boynton::BT709_TO_LMS_WEIGHTED_MAT, - 1.f, - 1.f); - tonemapped_bt709 = renodx::color::bt709::from::LMS(tonemapped_lms); -#else - tonemapped_bt709 = renodx::tonemap::psychov::psychotm_test23( - renodx::color::bt709::from::AP1(untonemapped_ap1), + float3 tonemapped_ap1 = ApplyCustomAnvilEnginePsychoV25ToneMap( + untonemapped_ap1, target_peak_ratio, - 1.f, - 1.f, - 1.f, - 1.f, - 1.f, - 1.f, - 100.f, - 1.f, - 1.f, - 0, - 1.18f, - 26.1406f.xxx, - 0.3671f.xxx, - 1.f, - 1, - 1.f, - 0.f); -#endif + linear_slope, + toe_end, + toe_power, + toe_offset, + toe_flare, + post_saturation, + shoulder_start); + tonemapped_bt709 = renodx::color::bt709::from::AP1(tonemapped_ap1); + } else { if (RENODX_GAME_GAMMA_CORRECTION != 0.f) { target_peak_ratio = renodx::color::correct::GammaSafe(target_peak_ratio, true); From 1278a4fe69f5036d111d8b2e93ed3e420acd4cf3 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Wed, 12 Aug 2026 18:12:33 -0400 Subject: [PATCH 08/22] feat(asscreedblackflagresynced): remove enhanced local tm, add new local exposure sliders, remove exposure slider --- src/games/asscreedblackflagresynced/addon.cpp | 52 ++--- src/games/asscreedblackflagresynced/shared.h | 16 +- ...calToneMappingApply_0xAAAF4B88.ps_6_0.hlsl | 178 +++--------------- 3 files changed, 68 insertions(+), 178 deletions(-) diff --git a/src/games/asscreedblackflagresynced/addon.cpp b/src/games/asscreedblackflagresynced/addon.cpp index e518f2abf..aa097439f 100644 --- a/src/games/asscreedblackflagresynced/addon.cpp +++ b/src/games/asscreedblackflagresynced/addon.cpp @@ -142,16 +142,6 @@ renodx::utils::settings::Settings settings = { .labels = {"Vanilla", "RenoDX (Vanilla+)", "RenoDX (Customized)"}, .on_change_value = &OnToneMapLutControlledSettingChanged, }, - new renodx::utils::settings::Setting{ - .key = "LocalToneMapType", - .binding = &shader_injection.custom_local_tone_map_type, - .value_type = renodx::utils::settings::SettingValueType::INTEGER, - .default_value = 0.f, - .label = "Local Tone Map Type", - .section = "Local Tone Mapping", - .tooltip = "Sets the local tone mapper type. Enhanced preserves prevents oversaturation in dark scenes.", - .labels = {"Vanilla", "Enhanced"}, - }, new renodx::utils::settings::Setting{ .value_type = renodx::utils::settings::SettingValueType::TEXT, .label = "Restart game to apply changes to UI Brightness.", @@ -173,14 +163,34 @@ renodx::utils::settings::Settings settings = { .on_change_value = &OnUiNitsSettingChanged, }, new renodx::utils::settings::Setting{ - .key = "Exposure", - .binding = &shader_injection.tone_map_exposure, - .default_value = 1.f, - .label = "Exposure", - .section = "Color Grading", - .max = 2.f, - .format = "%.2f", - .is_enabled = []() { return shader_injection.tone_map_type != 0; }, + .key = "LocalExposureStrength", + .binding = &shader_injection.custom_local_exposure_strength, + .default_value = 100.f, + .label = "Local Exposure Strength", + .section = "Local Exposure", + .tooltip = "Adjusts the strength of local exposure. At 0, only global auto exposure remains.", + .max = 100.f, + .parse = [](float value) { return value * 0.01f; }, + }, + new renodx::utils::settings::Setting{ + .key = "LocalExposureShoulder", + .binding = &shader_injection.custom_local_exposure_shoulder, + .default_value = 100.f, + .label = "Local Exposure Shoulder", + .section = "Local Exposure", + .tooltip = "Adjust the strength of local exposure shoulder", + .max = 100.f, + .parse = [](float value) { return value * 0.01f; }, + }, + new renodx::utils::settings::Setting{ + .key = "LocalExposureToe", + .binding = &shader_injection.custom_local_exposure_toe, + .default_value = 100.f, + .label = "Local Exposure Toe", + .section = "Local Exposure", + .tooltip = "Adjust the strength of local exposure toe", + .max = 100.f, + .parse = [](float value) { return value * 0.01f; }, }, new renodx::utils::settings::Setting{ .key = "ColorGradeHighlights", @@ -298,7 +308,6 @@ renodx::utils::settings::Settings settings = { renodx::utils::settings::ResetSettings(); renodx::utils::settings::UpdateSettings({ {"ToneMapType", 2.f}, - {"LocalToneMapType", 1.f}, {"ColorGradeCoolness", 50.f}, }); RefreshToneMapLutDirtyState(); @@ -371,9 +380,10 @@ renodx::utils::settings::Settings settings = { void OnPresetOff() { renodx::utils::settings::UpdateSettings({ {"ToneMapType", 0.f}, - {"LocalToneMapType", 0.f}, {"ToneMapUINits", 203.f}, - {"Exposure", 1.f}, + {"LocalExposureStrength", 1.f}, + {"LocalExposureShoulder", 1.f}, + {"LocalExposureToe", 1.f}, {"ColorGradeHighlights", 50.f}, {"ColorGradeShadows", 50.f}, {"ColorGradeContrast", 50.f}, diff --git a/src/games/asscreedblackflagresynced/shared.h b/src/games/asscreedblackflagresynced/shared.h index f8dbf0674..55becca00 100644 --- a/src/games/asscreedblackflagresynced/shared.h +++ b/src/games/asscreedblackflagresynced/shared.h @@ -6,10 +6,13 @@ struct ShaderInjectData { float tone_map_type; - float custom_local_tone_map_type; + float graphics_white_nits; + + float custom_local_exposure_strength; + float custom_local_exposure_shoulder; + float custom_local_exposure_toe; float custom_color_filter_strength; - float tone_map_exposure; float tone_map_highlights; float tone_map_shadows; float tone_map_contrast; @@ -19,8 +22,6 @@ struct ShaderInjectData { float tone_map_coolness; float custom_bloom; - - float graphics_white_nits; }; #ifndef __cplusplus @@ -36,12 +37,13 @@ cbuffer shader_injection : register(b13, space50) { #define RENODX_TONE_MAP_TYPE shader_injection.tone_map_type -#define CUSTOM_LOCAL_TONE_MAP_TYPE shader_injection.custom_local_tone_map_type - #define RENODX_GRAPHICS_WHITE_NITS shader_injection.graphics_white_nits +#define RENODX_LOCAL_EXPOSURE_STRENGTH shader_injection.custom_local_exposure_strength +#define RENODX_LOCAL_EXPOSURE_SHOULDER shader_injection.custom_local_exposure_shoulder +#define RENODX_LOCAL_EXPOSURE_TOE shader_injection.custom_local_exposure_toe + #define CUSTOM_COLOR_FILTER_STRENGTH shader_injection.custom_color_filter_strength -#define RENODX_TONE_MAP_EXPOSURE shader_injection.tone_map_exposure #define RENODX_TONE_MAP_HIGHLIGHTS shader_injection.tone_map_highlights #define RENODX_TONE_MAP_SHADOWS shader_injection.tone_map_shadows #define RENODX_TONE_MAP_CONTRAST shader_injection.tone_map_contrast diff --git a/src/games/asscreedblackflagresynced/tonemap/PS_LocalToneMappingApply_0xAAAF4B88.ps_6_0.hlsl b/src/games/asscreedblackflagresynced/tonemap/PS_LocalToneMappingApply_0xAAAF4B88.ps_6_0.hlsl index 842e7a2a9..06436a119 100644 --- a/src/games/asscreedblackflagresynced/tonemap/PS_LocalToneMappingApply_0xAAAF4B88.ps_6_0.hlsl +++ b/src/games/asscreedblackflagresynced/tonemap/PS_LocalToneMappingApply_0xAAAF4B88.ps_6_0.hlsl @@ -515,51 +515,6 @@ uint firstbithigh_msb(uint value) { return (value == 0) ? 0xFFFFFFFF : (31u - firstbithigh(value)); } -float3 HueAndChrominanceOKLab( - float3 incorrect_color, float3 reference_color, - float hue_correct_strength = 0.f, - float chrominance_correct_strength = 0.f, - float clamp_chrominance_loss = 0.f, - float clamp_chrominance_gain = 0.f, - float saturation = 1.f) { - if (hue_correct_strength != 0.f || chrominance_correct_strength != 0.f) { - float3 perceptual_new = renodx::color::oklab::from::BT709(incorrect_color); - const float3 reference_oklab = renodx::color::oklab::from::BT709(reference_color); - - float chrominance_current = length(perceptual_new.yz); - float chrominance_ratio_hue = 1.f; - float chrominance_ratio = 1.f; - - if (hue_correct_strength != 0.f) { - const float chrominance_pre = chrominance_current; - perceptual_new.yz = lerp(perceptual_new.yz, reference_oklab.yz, hue_correct_strength); - const float chrominancePost = length(perceptual_new.yz); - chrominance_ratio_hue = renodx::math::SafeDivision(chrominance_pre, chrominancePost, 1); - chrominance_current = chrominancePost; - } - - if (chrominance_correct_strength != 0.f) { - const float reference_chrominance = length(reference_oklab.yz); - float target_chrominance_ratio = renodx::math::SafeDivision(reference_chrominance, chrominance_current, 1); - chrominance_ratio = lerp(chrominance_ratio, target_chrominance_ratio, chrominance_correct_strength); - } - - // Combine hue-preservation scaling and chroma correction, then clamp gain/loss. - float chroma_scale = chrominance_ratio_hue * chrominance_ratio; - const float chroma_gain_mask = step(1.f, chroma_scale); // 1 when scaling up - const float chroma_loss_mask = 1.f - step(1.f, chroma_scale); // 1 when scaling down - chroma_scale = lerp(chroma_scale, 1.f, chroma_gain_mask * clamp_chrominance_gain); - chroma_scale = lerp(chroma_scale, 1.f, chroma_loss_mask * clamp_chrominance_loss); - - perceptual_new.yz *= chroma_scale; - perceptual_new.yz *= saturation; - - incorrect_color = renodx::color::bt709::from::OkLab(perceptual_new); - incorrect_color = renodx::color::bt709::clamp::AP1(incorrect_color); - } - return incorrect_color; -} - static const float LOCAL_TONEMAP_LUMINANCE_SCALE = 5464.f; static const float LOCAL_TONEMAP_INVERSE_LUMINANCE_SCALE = 0.0001830161054385826f; @@ -587,17 +542,14 @@ struct LocalToneMapParams { float local_adaptation_log; float input_log_slope; float local_detail_contribution; - float uncompressed_local_detail_contribution; }; struct LocalToneMapDetail { float contribution; - float uncompressed_contribution; }; struct LocalToneMapResult { float scale; - float scale_without_toe_and_shoulder; }; struct LocalToneMapSharedContext { @@ -670,7 +622,6 @@ LocalToneMapDetail ComputeLocalToneMapDetail( LocalToneMapDetail detail; detail.contribution = (1.f - detail_gain) * local_detail_delta * detail_adaptation; - detail.uncompressed_contribution = local_detail_delta * detail_adaptation; return detail; } @@ -708,10 +659,11 @@ LocalToneMapSharedContext ComputeLocalToneMapSharedContext(float2 texcoord) { * LOCAL_TONEMAP_LUMINANCE_SCALE); context.shoulder_gain = ComputeLimitedLocalDetailGain( - environment_log_delta, - context.config.shoulder_environment_scale, - context.config.shoulder_strength, - context.config.shoulder_max); + environment_log_delta, + context.config.shoulder_environment_scale, + context.config.shoulder_strength, + context.config.shoulder_max) + * RENODX_LOCAL_EXPOSURE_SHOULDER; const float negative_environment_delta = max( 0.f, @@ -720,7 +672,7 @@ LocalToneMapSharedContext ComputeLocalToneMapSharedContext(float2 texcoord) { negative_environment_delta, context.config.toe_environment_scale, context.config.toe_strength, - context.config.toe_max); + context.config.toe_max) * RENODX_LOCAL_EXPOSURE_TOE; return context; } @@ -737,7 +689,7 @@ LocalToneMapParams ComputeLocalToneMapParams( s8_space98, float3(context.texcoord, grid_z), 0.f); - const float bilateral_log = (bilateral_sample.x / max(bilateral_sample.y, 1.0000000116860974e-07f) + const float bilateral_log = (bilateral_sample.x / max(bilateral_sample.y, 1.0e-07f) + context.grid_min_log * context.inverse_grid_log_range) / context.inverse_grid_log_range; @@ -766,127 +718,53 @@ LocalToneMapParams ComputeLocalToneMapParams( context.config.slope_adaptation_strength, 1.f); params.local_detail_contribution = detail.contribution; - params.uncompressed_local_detail_contribution = detail.uncompressed_contribution; return params; } float SanitizeLocalToneMapScale(float scale) { const uint exponent_bits = asuint(scale) & 0x7F800000u; - const bool invalid_scale = isinf(scale) - || exponent_bits > 0x7F7FFFFFu; + const bool invalid_scale = isinf(scale) || exponent_bits > 0x7F7FFFFFu; return select(invalid_scale, 1.f, scale); } LocalToneMapResult ComputeLocalToneMapResult( float local_tonemap_input, - LocalToneMapParams params) { + LocalToneMapParams params, + LocalToneMapSharedContext context) { LocalToneMapResult result; if (local_tonemap_input == 0.f) { result.scale = 1.f; - result.scale_without_toe_and_shoulder = 1.f; return result; } - const float output_log_base = params.output_log_base - + params.input_log_slope - * (params.input_log - params.local_adaptation_log); - const float output_log_without_toe_and_shoulder = output_log_base - + params.uncompressed_local_detail_contribution; - const float output_log = output_log_base + params.local_detail_contribution; - result.scale_without_toe_and_shoulder = SanitizeLocalToneMapScale( - exp2(output_log_without_toe_and_shoulder) - * LOCAL_TONEMAP_INVERSE_LUMINANCE_SCALE - / local_tonemap_input); - result.scale = SanitizeLocalToneMapScale( - exp2(output_log) - * LOCAL_TONEMAP_INVERSE_LUMINANCE_SCALE - / local_tonemap_input); + const float local_output_log_base = params.output_log_base + params.input_log_slope * (params.input_log - params.local_adaptation_log); + const float local_output_log = local_output_log_base + params.local_detail_contribution; + float output_log = local_output_log; + [branch] + if (RENODX_LOCAL_EXPOSURE_STRENGTH != 1.f) { + const float global_adaptation_log = context.grid_min_log + context.config.grid_log_range * 0.5f; + const float global_positive_detail_mask = select(global_adaptation_log > context.output_log_base, 1.f, 0.f); + const float global_adaptation_strength = saturate((context.config.adaptation_log_threshold - context.reference_log) / context.config.adaptation_log_range) * global_positive_detail_mask; + const float global_input_log_slope = context.config.base_slope * mad(global_adaptation_strength, context.config.slope_adaptation_strength, 1.f); + const float global_detail_adaptation = mad(global_adaptation_strength, context.config.detail_adaptation_scale - 1.f, 1.f); + const float global_uncompressed_detail = (global_adaptation_log - context.output_log_base) * global_detail_adaptation; + const float global_output_log = context.output_log_base + global_input_log_slope * (params.input_log - global_adaptation_log) + global_uncompressed_detail; + output_log = lerp(global_output_log, local_output_log, saturate(RENODX_LOCAL_EXPOSURE_STRENGTH)); + } + result.scale = SanitizeLocalToneMapScale(exp2(output_log) * LOCAL_TONEMAP_INVERSE_LUMINANCE_SCALE / local_tonemap_input); return result; } -float3 ApplyLocalToneMapToeAndShoulderLMS( - float3 input_lms, - float3 precompression_lms, - float3 white_lms, - LocalToneMapParams luminance_params, - LocalToneMapSharedContext context) { - const float3 normalized_input_lms = input_lms / white_lms; - const float3 channel_input_logs = float3( - ComputeLocalToneMapInputLog(normalized_input_lms.x), - ComputeLocalToneMapInputLog(normalized_input_lms.y), - ComputeLocalToneMapInputLog(normalized_input_lms.z)); - - // Carry each cone's log offset into the luminance-derived local adaptation point. - const float3 channel_adaptation_logs = luminance_params.local_adaptation_log - + channel_input_logs - - luminance_params.input_log; - - // Select one smoothly varying toe/shoulder gain from luminance. Independent - // hard branch changes in L, M, and S produce visible chromatic gradients. - const float luminance_detail_delta = luminance_params.local_adaptation_log - - luminance_params.output_log_base; - const float shoulder_weight = smoothstep(-0.5f, 0.5f, luminance_detail_delta); - const float adaptation_strength = saturate( - (context.config.adaptation_log_threshold - context.reference_log) - / context.config.adaptation_log_range) - * shoulder_weight; - const float detail_gain = lerp(context.toe_gain, context.shoulder_gain, shoulder_weight); - const float detail_adaptation = mad( - adaptation_strength, - context.config.detail_adaptation_scale - 1.f, - 1.f); - const float3 channel_detail_deltas = channel_adaptation_logs - context.output_log_base; - const float3 toe_and_shoulder_log_delta = -detail_gain - * channel_detail_deltas - * detail_adaptation; - - // Apply only the toe/shoulder delta; exposure, grid adaptation, and slope stay luminance-driven. - const float3 normalized_precompression_lms = precompression_lms / white_lms; - const float3 normalized_tonemapped_lms = normalized_precompression_lms - * exp2(toe_and_shoulder_log_delta); - return normalized_tonemapped_lms * white_lms; -} - float4 main( precise noperspective float4 SV_Position: SV_Position, linear float2 TEXCOORD: TEXCOORD) : SV_Target { const float3 input_color = t0_space3.SampleLevel(s0_space99, TEXCOORD, 0.f).rgb; - const bool use_enhanced_local_tonemap = CUSTOM_LOCAL_TONE_MAP_TYPE != 0.f; - const float input_luminance = use_enhanced_local_tonemap - ? renodx::color::yf::from::BT709(input_color) - : renodx::color::y::from::BT709(input_color); + const float input_luminance = renodx::color::y::from::BT709(input_color); const LocalToneMapSharedContext context = ComputeLocalToneMapSharedContext(TEXCOORD); const LocalToneMapParams params = ComputeLocalToneMapParams(input_luminance, context); - const LocalToneMapResult local_tonemap = ComputeLocalToneMapResult(input_luminance, params); - const float3 local_tonemapped_color = local_tonemap.scale * input_color; - const float3 local_tonemapped_without_toe_and_shoulder = local_tonemap.scale_without_toe_and_shoulder * input_color; - - float3 output_color; - if (use_enhanced_local_tonemap) { - const float3 bt709_white_lms = renodx::color::lms::from::BT709(1.f.xxx); - const float3 input_lms = renodx::color::lms::from::BT709(input_color); - const float3 precompression_lms = renodx::color::lms::from::BT709(local_tonemapped_without_toe_and_shoulder); - float3 lms_tonemapped_color = renodx::color::bt709::from::LMS( - ApplyLocalToneMapToeAndShoulderLMS( - input_lms, - precompression_lms, - bt709_white_lms, - params, - context)); - lms_tonemapped_color = renodx::color::correct::Luminance( - lms_tonemapped_color, - renodx::color::yf::from::BT709(lms_tonemapped_color), - renodx::color::yf::from::BT709(local_tonemapped_color)); - output_color = lerp( - lms_tonemapped_color, - local_tonemapped_color, - saturate(renodx::color::yf::from::BT709(local_tonemapped_color) / 0.5f)); - output_color = lerp(output_color, local_tonemapped_color, 0.35f); - output_color = max(0, output_color); - } else { - output_color = local_tonemapped_color; - } + const LocalToneMapResult local_tonemap = ComputeLocalToneMapResult(input_luminance, params, context); + const float3 output_color = local_tonemap.scale * input_color; return float4(output_color, 1.f); } From 501a7dd97ff209733436f51453c91df5f12617de Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Wed, 12 Aug 2026 18:13:38 -0400 Subject: [PATCH 09/22] fix(asscreedblackflagresynced): fix local exposure with off preset --- src/games/asscreedblackflagresynced/addon.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/games/asscreedblackflagresynced/addon.cpp b/src/games/asscreedblackflagresynced/addon.cpp index aa097439f..4ecfa6d4b 100644 --- a/src/games/asscreedblackflagresynced/addon.cpp +++ b/src/games/asscreedblackflagresynced/addon.cpp @@ -381,9 +381,9 @@ void OnPresetOff() { renodx::utils::settings::UpdateSettings({ {"ToneMapType", 0.f}, {"ToneMapUINits", 203.f}, - {"LocalExposureStrength", 1.f}, - {"LocalExposureShoulder", 1.f}, - {"LocalExposureToe", 1.f}, + {"LocalExposureStrength", 100.f}, + {"LocalExposureShoulder", 100.f}, + {"LocalExposureToe", 100.f}, {"ColorGradeHighlights", 50.f}, {"ColorGradeShadows", 50.f}, {"ColorGradeContrast", 50.f}, From bf2a64a8450930e36e655772e8e573a79aa76e49 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Wed, 12 Aug 2026 20:11:50 -0400 Subject: [PATCH 10/22] feat(asscreedblackflagresynced): adjust customized tm params --- .../tonemap/tonemap.hlsli | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli b/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli index def6586c0..00bb21a52 100644 --- a/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli +++ b/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli @@ -152,12 +152,12 @@ float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float disp target_peak_ratio = 1.f; } - float linear_slope = 1.563f; - float shoulder_start = 0.48f; + float linear_slope = 1.625f; + float shoulder_start = 0.5f; float toe_end = 0.05f; - float toe_power = 1.31f; + float toe_power = 1.15f; float toe_offset = 0.f; - float toe_flare = 0.1f * pow(0.8f, 10.f); + float toe_flare = 0.1f * pow(0.875f, 10.f); float post_saturation = 1.f; float3 tonemapped_ap1 = ApplyCustomAnvilEnginePsychoV25ToneMap( @@ -190,10 +190,8 @@ float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float disp tonemapped_bt709 = renodx::color::bt709::from::AP1(tonemapped_ap1); const float output_anchor = 0.18f; - const float input_adaptive_anchor = - toe_end + ((output_anchor - toe_end) / linear_slope); - float3 input_adaptive_anchor_lms = - renodx::color::lms::from::AP1(input_adaptive_anchor.xxx); + const float input_adaptive_anchor = toe_end + ((output_anchor - toe_end) / linear_slope); + float3 input_adaptive_anchor_lms = renodx::color::lms::from::AP1(input_adaptive_anchor.xxx); float3 tonemapped_lms = renodx::color::lms::from::BT709(tonemapped_bt709); float3 tonemapped_relative_weighted = Psycho23ToAdaptiveRelativeWeightedLMS( tonemapped_lms, From 5a4aae45a0dd22e4f3e6392af3e94b39f21eb1a5 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Thu, 13 Aug 2026 01:37:46 -0400 Subject: [PATCH 11/22] feat(asscreedblackflagresynced): change tm shoulder start/anchor, use p3/bt709 gamut compression --- .../tonemap/customtest25.hlsli | 42 ++++++++++++------- .../tonemap/tonemap.hlsli | 31 ++++++++------ 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli b/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli index dff083837..8b850beb6 100644 --- a/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli +++ b/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli @@ -23,6 +23,12 @@ static const float PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY = 0.8f; static const float PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION = 0.25f; static const float PSYCHO25_SOURCE_DIRECTION_BLEND_POWER = 2.f; +static const int PSYCHO25_TARGET_GAMUT_BT709 = 0; +static const int PSYCHO25_TARGET_GAMUT_BT2020 = 1; +static const int PSYCHO25_TARGET_GAMUT_DISPLAY_P3 = 3; + +static const float3x3 PSYCHO25_LMS_WEIGHTED_TO_DISPLAY_P3_MAT = mul(renodx::color::XYZ_TO_DISPLAYP3_MAT, renodx::color::macleod_boynton::LMS_WEIGHTED_TO_XYZ_MAT); + float psycho25_SignedYfFromLMS(float3 lms) { float3 weighted_lms = renodx::color::macleod_boynton::WeighLMS(lms); return weighted_lms.x + weighted_lms.y; @@ -82,9 +88,13 @@ float3 psycho25_ApplyAdaptiveMBPurity( } float3x3 psycho25_WeightedLMSToRGBMatrix(int gamut_mode) { - return gamut_mode == 0 - ? renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT709_MAT - : renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT2020_MAT; + if (gamut_mode == PSYCHO25_TARGET_GAMUT_BT709) { + return renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT709_MAT; + } + if (gamut_mode == PSYCHO25_TARGET_GAMUT_DISPLAY_P3) { + return PSYCHO25_LMS_WEIGHTED_TO_DISPLAY_P3_MAT; + } + return renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT2020_MAT; } float3 psycho25_TargetRGBFromLMS(float3 lms, int gamut_mode) { @@ -358,8 +368,8 @@ APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR(float3) #undef APPLYANCHOREDCUBICSHOULDER_GENERATOR #undef APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR -// Fixed PsychoV25 target-hull path: Fast60 hue guidance, Reference Scale, -// full BT.2020 lower/upper-plane enforcement, and a black upper-hull pivot. +// PsychoV25 target-hull path: Fast60 hue guidance, Reference Scale, +// full target-gamut lower/upper-plane enforcement, and a black upper-hull pivot. float3 CompressPsychoV25ReferenceScaleHull( float3 desired_lms, float3 direction_source_lms, @@ -369,7 +379,8 @@ float3 CompressPsychoV25ReferenceScaleHull( float source_direction_recovery_strength, float post_saturation, float compression, - float peak_value) { + float peak_value, + int target_gamut_mode) { float3 desired_weighted_lms = renodx::color::macleod_boynton::WeighLMS(desired_lms); float desired_yf = desired_weighted_lms.x + desired_weighted_lms.y; if (desired_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { @@ -442,7 +453,7 @@ float3 CompressPsychoV25ReferenceScaleHull( source_direction, adapted_neutral_mb, adaptive_state_lms, - 1); + target_gamut_mode); float source_direction_support_radius = renodx::tonemap::psychov::PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY * source_radius_support @@ -498,7 +509,7 @@ float3 CompressPsychoV25ReferenceScaleHull( } // Discard the trajectory's carried scale, preserving only its authored - // adaptive-MB direction and radius before solving the BT.2020 hull. + // adaptive-MB direction and radius before solving the target gamut hull. float trajectory_yf_for_normalization = authored_mb.z * (authored_mb.x * safe_adaptive_state_lms.x + (1.f - authored_mb.x) * safe_adaptive_state_lms.y); @@ -513,12 +524,12 @@ float3 CompressPsychoV25ReferenceScaleHull( float3 neutral_lms = adaptive_state_lms / adaptive_yf; // Reference Scale lower-plane compression keeps the authored hue ray inside - // the nonnegative BT.2020 primary half-spaces without a component clamp. + // the nonnegative target-gamut primary half-spaces without a component clamp. if (authored_radius > renodx::tonemap::psychov::PSYCHO25_EPSILON) { float3 neutral_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(neutral_lms, 1); + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(neutral_lms, target_gamut_mode); float3 current_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, target_gamut_mode); float current_boundary_fraction = renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( current_target_rgb, @@ -539,7 +550,7 @@ float3 CompressPsychoV25ReferenceScaleHull( adaptive_state_lms); reference_lms /= renodx::tonemap::psychov::psycho25_YfFromLMS(reference_lms); float3 reference_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(reference_lms, 1); + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(reference_lms, target_gamut_mode); float reference_boundary_fraction = renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( reference_target_rgb, @@ -563,9 +574,9 @@ float3 CompressPsychoV25ReferenceScaleHull( unit_yf_lms = lerp(neutral_lms, unit_yf_lms, radius_scale); } - // Black-pivot upper-plane shoulder along the contained BT.2020 hue ray. + // Black-pivot upper-plane shoulder along the contained target-gamut hue ray. float3 unit_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, target_gamut_mode); float max_target_channel = max( unit_target_rgb.x, max(unit_target_rgb.y, unit_target_rgb.z)); @@ -655,7 +666,8 @@ float3 ApplyCustomPsychoV25ToneMap( source_direction_recovery_strength, 1.f, compression, - peak_value); + peak_value, + renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_BT2020); return renodx::color::bt709::from::LMS(output_lms); } diff --git a/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli b/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli index 00bb21a52..f0c9eccbb 100644 --- a/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli +++ b/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli @@ -61,7 +61,8 @@ float3 CompressAnvilEnginePsychoV25ReferenceScaleHull( float source_direction_recovery_strength, float post_saturation, float compression, - float peak_value) { + float peak_value, + int target_gamut_mode = renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_BT2020) { return CompressPsychoV25ReferenceScaleHull( desired_lms, direction_source_lms, @@ -71,7 +72,8 @@ float3 CompressAnvilEnginePsychoV25ReferenceScaleHull( source_direction_recovery_strength, post_saturation, compression, - peak_value); + peak_value, + target_gamut_mode); } float3 ApplyCustomAnvilEnginePsychoV25ToneMap( @@ -84,32 +86,32 @@ float3 ApplyCustomAnvilEnginePsychoV25ToneMap( float toe_flare, float post_saturation, float shoulder_start, + int target_gamut_mode = renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_BT2020, float source_direction_recovery_strength = 0.f, float compression = 1.f) { float3 white_lms = renodx::color::lms::from::AP1(1.f.xxx); float3 untonemapped_lms = max(renodx::color::lms::from::AP1(untonemapped_ap1), 0.f); - // The curve has no isolated inflection between its convex toe and concave shoulder. - // Anchor adaptation at the input that the linear section maps to SDR midgray, - // independently of the supplied C-infinity shoulder start. - static const float OUTPUT_ANCHOR = 0.18f; - float input_adaptive_anchor = toe_end + ((OUTPUT_ANCHOR - toe_end) / linear_slope); + // The shoulder operates on the toe/linear output. + // Use its output-domain start as the adaptive output anchor. + const float output_anchor = shoulder_start; + // Find the input whose toe/linear output reaches the shoulder start. + float input_adaptive_anchor = toe_end + ((shoulder_start - toe_end) / linear_slope); float3 input_adaptive_anchor_lms = input_adaptive_anchor * white_lms; float3 toe_linear_lms = EvaluateCustomAnvilEngineToeAndLinear(untonemapped_lms / white_lms, linear_slope, toe_end, toe_power, toe_offset, toe_flare) * white_lms; float3 peak_white_lms = peak_value * white_lms; - float toe_to_peak_output_range = peak_value - toe_end; - float shoulder_start_output = mad(toe_to_peak_output_range, shoulder_start, toe_end); return renodx::color::ap1::from::LMS(CompressAnvilEnginePsychoV25ReferenceScaleHull( toe_linear_lms, untonemapped_lms, input_adaptive_anchor_lms, peak_white_lms, - shoulder_start_output, + shoulder_start, source_direction_recovery_strength, post_saturation, compression, - peak_value)); + peak_value, + target_gamut_mode)); } float3 Psycho23ToAdaptiveRelativeWeightedLMS( @@ -148,12 +150,14 @@ float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float disp float3 tonemapped_bt709; if (RENODX_TONE_MAP_TYPE == 2.f) { + int target_gamut_mode = renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_DISPLAY_P3; if (!hdr_enabled) { target_peak_ratio = 1.f; + target_gamut_mode = renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_BT709; } float linear_slope = 1.625f; - float shoulder_start = 0.5f; + float shoulder_start = 0.18f; float toe_end = 0.05f; float toe_power = 1.15f; float toe_offset = 0.f; @@ -169,7 +173,8 @@ float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float disp toe_offset, toe_flare, post_saturation, - shoulder_start); + shoulder_start, + target_gamut_mode); tonemapped_bt709 = renodx::color::bt709::from::AP1(tonemapped_ap1); } else { From 4b50cae396625fe6e9c81dd9888ea2626c6739a8 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Thu, 13 Aug 2026 23:44:11 -0400 Subject: [PATCH 12/22] fix(asscreedblackflagresynced): preserve highlight hue shifts - Apply hue linearity before the per-cone shoulder so toe and shadow hueshifts do not compromise highlight hue authoring. - Rename the pre-shoulderlinearity and post-shoulder recovery parameters to reflect their behavior. --- .../tonemap/customtest25.hlsli | 92 +++++++++++-------- .../tonemap/tonemap.hlsli | 13 ++- 2 files changed, 63 insertions(+), 42 deletions(-) diff --git a/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli b/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli index 8b850beb6..a642f17f7 100644 --- a/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli +++ b/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli @@ -18,7 +18,6 @@ static const float PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE = 0.9f; static const float PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION = 0.75f; static const float PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON = 1e-5f; static const float PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER = 256.f; -static const float PSYCHO25_HUE_AMPLITUDE = 0.5f; static const float PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY = 0.8f; static const float PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION = 0.25f; static const float PSYCHO25_SOURCE_DIRECTION_BLEND_POWER = 2.f; @@ -376,7 +375,8 @@ float3 CompressPsychoV25ReferenceScaleHull( float3 adaptive_state_lms, float3 background_state_lms, float3 target_lms_peak, - float source_direction_recovery_strength, + float pre_shoulder_hue_linearity, + float post_shoulder_source_hue_recovery_strength, float post_saturation, float compression, float peak_value, @@ -390,62 +390,76 @@ float3 CompressPsychoV25ReferenceScaleHull( float adaptive_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(adaptive_state_lms); float background_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(background_state_lms); float target_peak_yf = renodx::tonemap::psychov::psycho25_SignedYfFromLMS(target_lms_peak); - float3 physical_compressed_lms = ApplyAnchoredCInfinityShoulder( - desired_lms, - target_lms_peak, - background_state_lms, - compression); - float authored_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(physical_compressed_lms); - if (authored_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { - return 0.f.xxx; - } - float3 safe_adaptive_state_lms = max( adaptive_state_lms, renodx::tonemap::psychov::PSYCHO25_EPSILON.xxx); float2 adapted_neutral_mb = renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - float3 authored_mb = renodx::color::macleod_boynton::from::WeightedLMS( - renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( - physical_compressed_lms, - adaptive_state_lms)); float3 source_mb = renodx::color::macleod_boynton::from::WeightedLMS( renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( direction_source_lms, adaptive_state_lms)); - // Fast60: retain physical radius and use the angular midpoint between the - // source direction and the raw per-cone-compressed direction. - float2 authored_offset = authored_mb.xy - adapted_neutral_mb; + // Hue linearity authors the shoulder input rather than correcting its + // output. Blend the desired adaptive-MB direction toward the source while + // retaining the desired radius and Yf, then run the per-cone shoulder. + float3 shoulder_input_lms = desired_lms; + float3 desired_mb = renodx::color::macleod_boynton::from::WeightedLMS( + renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( + desired_lms, + adaptive_state_lms)); + float2 desired_offset = desired_mb.xy - adapted_neutral_mb; float2 source_offset = source_mb.xy - adapted_neutral_mb; - float authored_radius2 = dot(authored_offset, authored_offset); + float desired_radius2 = dot(desired_offset, desired_offset); float source_radius2 = dot(source_offset, source_offset); - if (authored_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON + if (pre_shoulder_hue_linearity > 0.f + && desired_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON && source_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON) { + float2 desired_direction = desired_offset * rsqrt(desired_radius2); float2 source_direction = source_offset * rsqrt(source_radius2); - float2 compressed_direction = authored_offset * rsqrt(authored_radius2); - float2 output_direction = lerp( + float2 shoulder_input_direction = lerp( + desired_direction, source_direction, - compressed_direction, - 1.f - renodx::tonemap::psychov::PSYCHO25_HUE_AMPLITUDE); - float output_direction2 = dot(output_direction, output_direction); - if (output_direction2 > renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON) { - authored_mb.xy = adapted_neutral_mb - + output_direction * rsqrt(output_direction2) * sqrt(authored_radius2); - authored_offset = authored_mb.xy - adapted_neutral_mb; - authored_radius2 = dot(authored_offset, authored_offset); - } + saturate(pre_shoulder_hue_linearity)); + shoulder_input_direction *= rsqrt( + dot(shoulder_input_direction, shoulder_input_direction)); + float2 shoulder_input_mb_xy = adapted_neutral_mb + + shoulder_input_direction * sqrt(desired_radius2); + float shoulder_input_mb_scale = renodx::math::DivideSafe( + desired_yf, + shoulder_input_mb_xy.x * safe_adaptive_state_lms.x + + (1.f - shoulder_input_mb_xy.x) * safe_adaptive_state_lms.y, + 0.f); + shoulder_input_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( + float3(shoulder_input_mb_xy, shoulder_input_mb_scale), + adaptive_state_lms); + } + + float3 physical_compressed_lms = ApplyAnchoredCInfinityShoulder( + shoulder_input_lms, + target_lms_peak, + background_state_lms, + compression); + float authored_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(physical_compressed_lms); + if (authored_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { + return 0.f.xxx; } + float3 authored_mb = renodx::color::macleod_boynton::from::WeightedLMS( + renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( + physical_compressed_lms, + adaptive_state_lms)); + float2 authored_offset = authored_mb.xy - adapted_neutral_mb; + float authored_radius2 = dot(authored_offset, authored_offset); + float authored_radius = sqrt(authored_radius2); float2 authored_direction = authored_offset * rsqrt(authored_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); // Reference Scale source-direction recovery keeps collapsing saturated // highlights from rotating through an unrelated hue on their way to white. [branch] - if (source_direction_recovery_strength > 0.f) { + if (post_shoulder_source_hue_recovery_strength > 0.f) { float source_radius = sqrt(source_radius2); float2 source_direction = source_offset * rsqrt(source_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); float source_radius_support = @@ -481,7 +495,7 @@ float3 CompressPsychoV25ReferenceScaleHull( source_direction_support_weight, authored_weight + source_direction_support_weight, 0.f); - float source_direction_weight = source_direction_recovery_strength + float source_direction_weight = post_shoulder_source_hue_recovery_strength * (1.f - (1.f - source_hue_confidence) * (1.f - source_collapse_weight)); float2 combined_direction = lerp( authored_direction, @@ -601,7 +615,8 @@ float3 ApplyCustomPsychoV25ToneMap( float purity_scale, float highlight_saturation, float dechroma, - float source_direction_recovery_strength = 0.f, + float pre_shoulder_hue_linearity = 0.35f, + float post_shoulder_source_hue_recovery_strength = 0.f, float3 current_adaptive_state_bt709 = 0.18f, float3 current_background_state_bt709 = 0.18f, float compression = 1.5f) { @@ -663,7 +678,8 @@ float3 ApplyCustomPsychoV25ToneMap( current_adaptive_state_lms, current_background_state_lms, target_lms_peak, - source_direction_recovery_strength, + pre_shoulder_hue_linearity, + post_shoulder_source_hue_recovery_strength, 1.f, compression, peak_value, diff --git a/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli b/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli index f0c9eccbb..717c3b950 100644 --- a/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli +++ b/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli @@ -58,7 +58,8 @@ float3 CompressAnvilEnginePsychoV25ReferenceScaleHull( float3 adaptive_state_lms, float3 target_lms_peak, float shoulder_start_output, - float source_direction_recovery_strength, + float pre_shoulder_hue_linearity, + float post_shoulder_source_hue_recovery_strength, float post_saturation, float compression, float peak_value, @@ -69,7 +70,8 @@ float3 CompressAnvilEnginePsychoV25ReferenceScaleHull( adaptive_state_lms, renodx::color::lms::from::AP1(shoulder_start_output.xxx), target_lms_peak, - source_direction_recovery_strength, + pre_shoulder_hue_linearity, + post_shoulder_source_hue_recovery_strength, post_saturation, compression, peak_value, @@ -86,8 +88,9 @@ float3 ApplyCustomAnvilEnginePsychoV25ToneMap( float toe_flare, float post_saturation, float shoulder_start, + float pre_shoulder_hue_linearity = 0.35f, int target_gamut_mode = renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_BT2020, - float source_direction_recovery_strength = 0.f, + float post_shoulder_source_hue_recovery_strength = 0.f, float compression = 1.f) { float3 white_lms = renodx::color::lms::from::AP1(1.f.xxx); float3 untonemapped_lms = max(renodx::color::lms::from::AP1(untonemapped_ap1), 0.f); @@ -107,7 +110,8 @@ float3 ApplyCustomAnvilEnginePsychoV25ToneMap( input_adaptive_anchor_lms, peak_white_lms, shoulder_start, - source_direction_recovery_strength, + pre_shoulder_hue_linearity, + post_shoulder_source_hue_recovery_strength, post_saturation, compression, peak_value, @@ -174,6 +178,7 @@ float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float disp toe_flare, post_saturation, shoulder_start, + 0.5f, target_gamut_mode); tonemapped_bt709 = renodx::color::bt709::from::AP1(tonemapped_ap1); From ae131ac14d6f37935be092d09a2a1bc6953ae3f9 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Fri, 14 Aug 2026 00:52:02 -0400 Subject: [PATCH 13/22] fix(elitedangerous): preserve highlight hue shifts, use display p3 gamut compression - Apply hue linearity before the per-cone shoulder so toe and shadow hueshifts do not compromise highlight hue authoring. - Rename the pre-shoulderlinearity and post-shoulder recovery parameters to reflect their behavior. --- .../tonemap/psychov25/customtest25.hlsli | 137 +++++++++++------- .../elitedangerous/tonemap/tonemap.hlsli | 6 +- 2 files changed, 87 insertions(+), 56 deletions(-) diff --git a/src/games/elitedangerous/tonemap/psychov25/customtest25.hlsli b/src/games/elitedangerous/tonemap/psychov25/customtest25.hlsli index df2004d50..847c53986 100644 --- a/src/games/elitedangerous/tonemap/psychov25/customtest25.hlsli +++ b/src/games/elitedangerous/tonemap/psychov25/customtest25.hlsli @@ -18,11 +18,16 @@ static const float PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE = 0.9f; static const float PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION = 0.75f; static const float PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON = 1e-5f; static const float PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER = 256.f; -static const float PSYCHO25_HUE_AMPLITUDE = 0.5f; static const float PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY = 0.8f; static const float PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION = 0.25f; static const float PSYCHO25_SOURCE_DIRECTION_BLEND_POWER = 2.f; +static const int PSYCHO25_TARGET_GAMUT_BT709 = 0; +static const int PSYCHO25_TARGET_GAMUT_BT2020 = 1; +static const int PSYCHO25_TARGET_GAMUT_DISPLAY_P3 = 3; + +static const float3x3 PSYCHO25_LMS_WEIGHTED_TO_DISPLAY_P3_MAT = mul(renodx::color::XYZ_TO_DISPLAYP3_MAT, renodx::color::macleod_boynton::LMS_WEIGHTED_TO_XYZ_MAT); + float psycho25_SignedYfFromLMS(float3 lms) { float3 weighted_lms = renodx::color::macleod_boynton::WeighLMS(lms); return weighted_lms.x + weighted_lms.y; @@ -82,9 +87,13 @@ float3 psycho25_ApplyAdaptiveMBPurity( } float3x3 psycho25_WeightedLMSToRGBMatrix(int gamut_mode) { - return gamut_mode == 0 - ? renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT709_MAT - : renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT2020_MAT; + if (gamut_mode == PSYCHO25_TARGET_GAMUT_BT709) { + return renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT709_MAT; + } + if (gamut_mode == PSYCHO25_TARGET_GAMUT_DISPLAY_P3) { + return PSYCHO25_LMS_WEIGHTED_TO_DISPLAY_P3_MAT; + } + return renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT2020_MAT; } float3 psycho25_TargetRGBFromLMS(float3 lms, int gamut_mode) { @@ -318,17 +327,19 @@ float ApplyAnchoredCInfinityShoulderMaxChannelScale(float3 color, float peak, fl return renodx::math::DivideSafe(compressed_max, max_channel, 1.f); } -// Fixed PsychoV25 target-hull path: Fast60 hue guidance, Reference Scale, -// full BT.2020 lower/upper-plane enforcement, and a black upper-hull pivot. +// PsychoV25 target-hull path: Fast60 hue guidance, Reference Scale, +// full target-gamut lower/upper-plane enforcement, and a black upper-hull pivot. float3 CompressPsychoV25ReferenceScaleHull( float3 desired_lms, float3 direction_source_lms, float3 adaptive_state_lms, float3 background_state_lms, float3 target_lms_peak, - float source_direction_recovery_strength, + float pre_shoulder_hue_linearity, + float post_shoulder_source_hue_recovery_strength, float compression, - float peak_value) { + float peak_value, + int target_gamut_mode) { float3 desired_weighted_lms = renodx::color::macleod_boynton::WeighLMS(desired_lms); float desired_yf = desired_weighted_lms.x + desired_weighted_lms.y; if (desired_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { @@ -338,62 +349,76 @@ float3 CompressPsychoV25ReferenceScaleHull( float adaptive_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(adaptive_state_lms); float background_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(background_state_lms); float target_peak_yf = renodx::tonemap::psychov::psycho25_SignedYfFromLMS(target_lms_peak); - float3 physical_compressed_lms = ApplyAnchoredCInfinityShoulder( - desired_lms, - target_lms_peak, - background_state_lms, - compression); - float authored_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(physical_compressed_lms); - if (authored_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { - return 0.f.xxx; - } - float3 safe_adaptive_state_lms = max( adaptive_state_lms, renodx::tonemap::psychov::PSYCHO25_EPSILON.xxx); float2 adapted_neutral_mb = renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - float3 authored_mb = renodx::color::macleod_boynton::from::WeightedLMS( - renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( - physical_compressed_lms, - adaptive_state_lms)); float3 source_mb = renodx::color::macleod_boynton::from::WeightedLMS( renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( direction_source_lms, adaptive_state_lms)); - // Fast60: retain physical radius and use the angular midpoint between the - // source direction and the raw per-cone-compressed direction. - float2 authored_offset = authored_mb.xy - adapted_neutral_mb; + // Hue linearity authors the shoulder input rather than correcting its + // output. Blend the desired adaptive-MB direction toward the source while + // retaining the desired radius and Yf, then run the per-cone shoulder. + float3 shoulder_input_lms = desired_lms; + float3 desired_mb = renodx::color::macleod_boynton::from::WeightedLMS( + renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( + desired_lms, + adaptive_state_lms)); + float2 desired_offset = desired_mb.xy - adapted_neutral_mb; float2 source_offset = source_mb.xy - adapted_neutral_mb; - float authored_radius2 = dot(authored_offset, authored_offset); + float desired_radius2 = dot(desired_offset, desired_offset); float source_radius2 = dot(source_offset, source_offset); - if (authored_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON + if (pre_shoulder_hue_linearity > 0.f + && desired_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON + * renodx::tonemap::psychov::PSYCHO25_EPSILON && source_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON) { + float2 desired_direction = desired_offset * rsqrt(desired_radius2); float2 source_direction = source_offset * rsqrt(source_radius2); - float2 compressed_direction = authored_offset * rsqrt(authored_radius2); - float2 output_direction = lerp( + float2 shoulder_input_direction = lerp( + desired_direction, source_direction, - compressed_direction, - 1.f - renodx::tonemap::psychov::PSYCHO25_HUE_AMPLITUDE); - float output_direction2 = dot(output_direction, output_direction); - if (output_direction2 > renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON) { - authored_mb.xy = adapted_neutral_mb - + output_direction * rsqrt(output_direction2) * sqrt(authored_radius2); - authored_offset = authored_mb.xy - adapted_neutral_mb; - authored_radius2 = dot(authored_offset, authored_offset); - } + saturate(pre_shoulder_hue_linearity)); + shoulder_input_direction *= rsqrt( + dot(shoulder_input_direction, shoulder_input_direction)); + float2 shoulder_input_mb_xy = adapted_neutral_mb + + shoulder_input_direction * sqrt(desired_radius2); + float shoulder_input_mb_scale = renodx::math::DivideSafe( + desired_yf, + shoulder_input_mb_xy.x * safe_adaptive_state_lms.x + + (1.f - shoulder_input_mb_xy.x) * safe_adaptive_state_lms.y, + 0.f); + shoulder_input_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( + float3(shoulder_input_mb_xy, shoulder_input_mb_scale), + adaptive_state_lms); } + float3 physical_compressed_lms = ApplyAnchoredCInfinityShoulder( + shoulder_input_lms, + target_lms_peak, + background_state_lms, + compression); + float authored_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(physical_compressed_lms); + if (authored_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { + return 0.f.xxx; + } + + float3 authored_mb = renodx::color::macleod_boynton::from::WeightedLMS( + renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( + physical_compressed_lms, + adaptive_state_lms)); + float2 authored_offset = authored_mb.xy - adapted_neutral_mb; + float authored_radius2 = dot(authored_offset, authored_offset); + float authored_radius = sqrt(authored_radius2); float2 authored_direction = authored_offset * rsqrt(authored_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); // Reference Scale source-direction recovery keeps collapsing saturated // highlights from rotating through an unrelated hue on their way to white. [branch] - if (source_direction_recovery_strength > 0.f) { + if (post_shoulder_source_hue_recovery_strength > 0.f) { float source_radius = sqrt(source_radius2); float2 source_direction = source_offset * rsqrt(source_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); float source_radius_support = @@ -401,7 +426,7 @@ float3 CompressPsychoV25ReferenceScaleHull( source_direction, adapted_neutral_mb, adaptive_state_lms, - 1); + target_gamut_mode); float source_direction_support_radius = renodx::tonemap::psychov::PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY * source_radius_support @@ -429,7 +454,7 @@ float3 CompressPsychoV25ReferenceScaleHull( source_direction_support_weight, authored_weight + source_direction_support_weight, 0.f); - float source_direction_weight = source_direction_recovery_strength + float source_direction_weight = post_shoulder_source_hue_recovery_strength * (1.f - (1.f - source_hue_confidence) * (1.f - source_collapse_weight)); float2 combined_direction = lerp( authored_direction, @@ -445,7 +470,7 @@ float3 CompressPsychoV25ReferenceScaleHull( } // Discard the trajectory's carried scale, preserving only its authored - // adaptive-MB direction and radius before solving the BT.2020 hull. + // adaptive-MB direction and radius before solving the target gamut hull. float trajectory_yf_for_normalization = authored_mb.z * (authored_mb.x * safe_adaptive_state_lms.x + (1.f - authored_mb.x) * safe_adaptive_state_lms.y); @@ -460,12 +485,12 @@ float3 CompressPsychoV25ReferenceScaleHull( float3 neutral_lms = adaptive_state_lms / adaptive_yf; // Reference Scale lower-plane compression keeps the authored hue ray inside - // the nonnegative BT.2020 primary half-spaces without a component clamp. + // the nonnegative target-gamut primary half-spaces without a component clamp. if (authored_radius > renodx::tonemap::psychov::PSYCHO25_EPSILON) { float3 neutral_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(neutral_lms, 1); + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(neutral_lms, target_gamut_mode); float3 current_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, target_gamut_mode); float current_boundary_fraction = renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( current_target_rgb, @@ -486,7 +511,7 @@ float3 CompressPsychoV25ReferenceScaleHull( adaptive_state_lms); reference_lms /= renodx::tonemap::psychov::psycho25_YfFromLMS(reference_lms); float3 reference_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(reference_lms, 1); + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(reference_lms, target_gamut_mode); float reference_boundary_fraction = renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( reference_target_rgb, @@ -510,9 +535,9 @@ float3 CompressPsychoV25ReferenceScaleHull( unit_yf_lms = lerp(neutral_lms, unit_yf_lms, radius_scale); } - // Black-pivot upper-plane shoulder along the contained BT.2020 hue ray. + // Black-pivot upper-plane shoulder along the contained target-gamut hue ray. float3 unit_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, 1); + renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, target_gamut_mode); float max_target_channel = max( unit_target_rgb.x, max(unit_target_rgb.y, unit_target_rgb.z)); @@ -537,10 +562,12 @@ float3 ApplyCustomPsychoV25ToneMap( float purity_scale, float highlight_saturation, float dechroma, - float source_direction_recovery_strength = 0.f, float3 current_adaptive_state_bt709 = 0.18f, float3 current_background_state_bt709 = 0.18f, - float compression = 1.5f) { + float pre_shoulder_hue_linearity = 0.5f, + float post_shoulder_source_hue_recovery_strength = 0.35f, + float compression = 1.5f, + int target_gamut_mode = renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_BT2020) { float3 finite_bt709_input = renodx::math::ZeroNaN(bt709_linear_input); finite_bt709_input = renodx::math::Select( isinf(finite_bt709_input), @@ -599,9 +626,11 @@ float3 ApplyCustomPsychoV25ToneMap( current_adaptive_state_lms, current_background_state_lms, target_lms_peak, - source_direction_recovery_strength, + pre_shoulder_hue_linearity, + post_shoulder_source_hue_recovery_strength, compression, - peak_value); + peak_value, + target_gamut_mode); return renodx::color::bt709::from::LMS(output_lms); } diff --git a/src/games/elitedangerous/tonemap/tonemap.hlsli b/src/games/elitedangerous/tonemap/tonemap.hlsli index abfe9be56..13c07a3d8 100644 --- a/src/games/elitedangerous/tonemap/tonemap.hlsli +++ b/src/games/elitedangerous/tonemap/tonemap.hlsli @@ -226,10 +226,12 @@ float3 ApplyPostLUTToneMap(float3 untonemapped_gamma) { RENODX_TONE_MAP_SATURATION, RENODX_TONE_MAP_HIGHLIGHT_SATURATION, RENODX_TONE_MAP_DECHROMA, - 0.f, MID_GRAY_IN, MID_GRAY_OUT, - 1.5f); + 0.35f, + 0.3, + 1.5f, + renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_DISPLAY_P3); } return renodx::color::gamma::EncodeSafe(tonemapped, 2.2f); From ef0b42d74ec25391fdcd9902442d9054eee21b2f Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Sun, 16 Aug 2026 16:46:13 -0400 Subject: [PATCH 14/22] feat(ffxvi): increase hue shifting --- src/games/ffxvi/macleod_boynton.hlsli | 6 ++++-- src/games/ffxvi/tonemap.hlsli | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/games/ffxvi/macleod_boynton.hlsli b/src/games/ffxvi/macleod_boynton.hlsli index 31f7f26a6..b86eaa393 100644 --- a/src/games/ffxvi/macleod_boynton.hlsli +++ b/src/games/ffxvi/macleod_boynton.hlsli @@ -723,12 +723,13 @@ float3 CorrectHueAndPurityMBGated( // Single-reference MB correction with luminosity-gated hue: // - Purity from reference is always applied at full strength. -// - Hue from reference is gated by LMS luminosity ramp (1.55 * L + M). +// - Hue from reference ramps from a configurable minimum by LMS luminosity (1.55 * L + M). float3 CorrectHueLuminosityGatedAndPurityMB( float3 target_color_bt709, float3 reference_color_bt709, float hue_lum_ramp_start = 0.5f, float hue_lum_ramp_end = 1.f, + float minimum_hue_blend = 0.f, float purity_scale = 1.f, float curve_gamma = 1.f, float2 mb_white_override = float2(-1.f, -1.f), @@ -745,8 +746,9 @@ float3 CorrectHueLuminosityGatedAndPurityMB( } float target_luminosity = 1.55f * target_lms.x + target_lms.y; - float hue_blend = saturate(renodx::math::DivideSafe( + float hue_ramp = saturate(renodx::math::DivideSafe( target_luminosity - hue_lum_ramp_start, hue_lum_ramp_end - hue_lum_ramp_start, 0.f)); + float hue_blend = lerp(minimum_hue_blend, 1.f, hue_ramp); float2 white = (mb_white_override.x >= 0.f && mb_white_override.y >= 0.f) ? mb_white_override diff --git a/src/games/ffxvi/tonemap.hlsli b/src/games/ffxvi/tonemap.hlsli index d3d58efe5..37390f11e 100644 --- a/src/games/ffxvi/tonemap.hlsli +++ b/src/games/ffxvi/tonemap.hlsli @@ -374,7 +374,7 @@ float3 ApplyToneMap(float3 untonemapped, float peak_ratio) { tonemapped = untonemapped * luminosity_scale; // use perch purity and use perch hues only on highlights - tonemapped = CorrectHueLuminosityGatedAndPurityMB(tonemapped, ch_tonemapped, 1.f, 2.f, 1.025f); + tonemapped = CorrectHueLuminosityGatedAndPurityMB(tonemapped, ch_tonemapped, 1.f, 2.f, 0.5f, 1.025f); } else { tonemapped = untonemapped; } From 4d70a2d3152747389aae36103f5d8de0644e1168 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Wed, 19 Aug 2026 21:19:39 -0400 Subject: [PATCH 15/22] feat(deadspace): add force hdr10 define, enable presets --- src/games/deadspace/addon.cpp | 120 +++++++++++------- .../composite_0x2F62371D.ps_5_0.hlsl | 6 + src/games/deadspace/shared.h | 68 +++++----- 3 files changed, 119 insertions(+), 75 deletions(-) diff --git a/src/games/deadspace/addon.cpp b/src/games/deadspace/addon.cpp index 39b383067..a653958c2 100644 --- a/src/games/deadspace/addon.cpp +++ b/src/games/deadspace/addon.cpp @@ -17,6 +17,10 @@ #include "../../mods/shader.hpp" #include "../../utils/date.hpp" #include "../../utils/settings.hpp" +#include "shared.h" +#if DEADSPACE_FORCE_HDR10 +#include "../../utils/swapchain.hpp" +#endif #include "pipeline_layouts.hpp" #include "resource_upgrades.hpp" @@ -32,67 +36,67 @@ float applied_game_nits = 152.32879f; float applied_tone_map_working_color_space = 1.f; void SetToneMapLutInvalidated(bool invalidated) { - tone_map_lut_invalidated.store(invalidated, std::memory_order_relaxed); + tone_map_lut_invalidated.store(invalidated, std::memory_order_relaxed); } bool ToneMapLutValuesDirty() { - return shader_injection.tone_map_type != applied_tone_map_type - || shader_injection.peak_white_nits != applied_peak_nits - || shader_injection.override_game_brightness != applied_override_game_brightness - || shader_injection.diffuse_white_nits != applied_game_nits - || shader_injection.tone_map_working_color_space != applied_tone_map_working_color_space; + return shader_injection.tone_map_type != applied_tone_map_type + || shader_injection.peak_white_nits != applied_peak_nits + || shader_injection.override_game_brightness != applied_override_game_brightness + || shader_injection.diffuse_white_nits != applied_game_nits + || shader_injection.tone_map_working_color_space != applied_tone_map_working_color_space; } void RefreshToneMapLutDirtyState() { - SetToneMapLutInvalidated(ToneMapLutValuesDirty()); + SetToneMapLutInvalidated(ToneMapLutValuesDirty()); } void MarkToneMapLutApplied() { - applied_tone_map_type = shader_injection.tone_map_type; - applied_peak_nits = shader_injection.peak_white_nits; - applied_override_game_brightness = shader_injection.override_game_brightness; - applied_game_nits = shader_injection.diffuse_white_nits; - applied_tone_map_working_color_space = shader_injection.tone_map_working_color_space; - RefreshToneMapLutDirtyState(); + applied_tone_map_type = shader_injection.tone_map_type; + applied_peak_nits = shader_injection.peak_white_nits; + applied_override_game_brightness = shader_injection.override_game_brightness; + applied_game_nits = shader_injection.diffuse_white_nits; + applied_tone_map_working_color_space = shader_injection.tone_map_working_color_space; + RefreshToneMapLutDirtyState(); } void InitializeAppliedValues() { - applied_tone_map_type = shader_injection.tone_map_type; - applied_peak_nits = shader_injection.peak_white_nits; - applied_override_game_brightness = shader_injection.override_game_brightness; - applied_game_nits = shader_injection.diffuse_white_nits; - applied_tone_map_working_color_space = shader_injection.tone_map_working_color_space; + applied_tone_map_type = shader_injection.tone_map_type; + applied_peak_nits = shader_injection.peak_white_nits; + applied_override_game_brightness = shader_injection.override_game_brightness; + applied_game_nits = shader_injection.diffuse_white_nits; + applied_tone_map_working_color_space = shader_injection.tone_map_working_color_space; } void OnToneMapLutBuilderDrawn(reshade::api::command_list* /*cmd_list*/) { - MarkToneMapLutApplied(); + MarkToneMapLutApplied(); } void OnToneMapLutControlledSettingChanged(float /*previous*/, float /*current*/) { - RefreshToneMapLutDirtyState(); + RefreshToneMapLutDirtyState(); } void OnPresetChangedInvalidateIfChanged() { - RefreshToneMapLutDirtyState(); + RefreshToneMapLutDirtyState(); } renodx::mods::shader::CustomShaders custom_shaders = { - {0xEC2192B4, { - .crc32 = 0xEC2192B4, - .code = __0xEC2192B4, - .on_drawn = &OnToneMapLutBuilderDrawn, - }}, - {0x6F0456CD, { - .crc32 = 0x6F0456CD, - .code = __0x6F0456CD, - .on_drawn = &OnToneMapLutBuilderDrawn, - }}, - {0xD97D273F, { - .crc32 = 0xD97D273F, - .code = __0xD97D273F, - .on_drawn = &OnToneMapLutBuilderDrawn, - }}, - __ALL_CUSTOM_SHADERS}; + {0xEC2192B4, { + .crc32 = 0xEC2192B4, + .code = __0xEC2192B4, + .on_drawn = &OnToneMapLutBuilderDrawn, + }}, + {0x6F0456CD, { + .crc32 = 0x6F0456CD, + .code = __0x6F0456CD, + .on_drawn = &OnToneMapLutBuilderDrawn, + }}, + {0xD97D273F, { + .crc32 = 0xD97D273F, + .code = __0xD97D273F, + .on_drawn = &OnToneMapLutBuilderDrawn, + }}, + __ALL_CUSTOM_SHADERS}; bool ShouldInjectShaderCBuffer( reshade::api::device* device, @@ -104,6 +108,26 @@ bool ShouldInjectShaderCBuffer( return allow_injection; } +#if DEADSPACE_FORCE_HDR10 +bool OnCreateSwapchain(reshade::api::device_api device_api, reshade::api::swapchain_desc& desc, void* /*hwnd*/) { + if (device_api != reshade::api::device_api::d3d12 || desc.back_buffer.texture.format != reshade::api::format::r16g16b16a16_float) { + return false; + } + + desc.back_buffer.texture.format = reshade::api::format::r10g10b10a2_unorm; + return true; +} + +void OnInitSwapchain(reshade::api::swapchain* swapchain, bool /*resize*/) { + if (swapchain->get_device()->get_api() != reshade::api::device_api::d3d12) return; + + const auto back_buffer_desc = swapchain->get_device()->get_resource_desc(swapchain->get_current_back_buffer()); + if (back_buffer_desc.texture.format != reshade::api::format::r10g10b10a2_unorm) return; + + renodx::utils::swapchain::ChangeColorSpace(swapchain, reshade::api::color_space::hdr10_st2084); +} +#endif + renodx::utils::settings::Settings settings = { new renodx::utils::settings::Setting{ .key = "ToneMapType", @@ -401,7 +425,7 @@ void OnPresetOff() { {"ColorGradeFlare", 0.f}, }); - RefreshToneMapLutDirtyState(); + RefreshToneMapLutDirtyState(); } bool initialized = false; @@ -426,21 +450,29 @@ BOOL APIENTRY DllMain(HMODULE h_module, DWORD fdw_reason, LPVOID lpv_reserved) { }; if (!initialized) { - renodx::utils::settings::use_presets = false; renodx::mods::shader::allow_multiple_push_constants = true; renodx::mods::shader::expected_constant_buffer_index = 13; renodx::mods::shader::expected_constant_buffer_space = 0; renodx::mods::shader::force_pipeline_cloning = true; - renodx::utils::settings::on_preset_changed_callbacks.emplace_back(&OnPresetChangedInvalidateIfChanged); + renodx::utils::settings::on_preset_changed_callbacks.emplace_back(&OnPresetChangedInvalidateIfChanged); initialized = true; } + +#if DEADSPACE_FORCE_HDR10 + reshade::register_event(OnCreateSwapchain); + reshade::register_event(OnInitSwapchain); +#endif #if DEADSPACE_ENABLE_RESOURCE_UPGRADES deadspace::resource_upgrades::Register(); #endif break; case DLL_PROCESS_DETACH: +#if DEADSPACE_FORCE_HDR10 + reshade::unregister_event(OnCreateSwapchain); + reshade::unregister_event(OnInitSwapchain); +#endif #if DEADSPACE_ENABLE_RESOURCE_UPGRADES deadspace::resource_upgrades::Unregister(); #endif @@ -450,10 +482,10 @@ BOOL APIENTRY DllMain(HMODULE h_module, DWORD fdw_reason, LPVOID lpv_reserved) { } renodx::utils::settings::Use(fdw_reason, &settings, &OnPresetOff); - if (fdw_reason == DLL_PROCESS_ATTACH) { - InitializeAppliedValues(); - RefreshToneMapLutDirtyState(); - } + if (fdw_reason == DLL_PROCESS_ATTACH) { + InitializeAppliedValues(); + RefreshToneMapLutDirtyState(); + } renodx::mods::shader::Use(fdw_reason, custom_shaders, &shader_injection); return TRUE; diff --git a/src/games/deadspace/composite_0x2F62371D.ps_5_0.hlsl b/src/games/deadspace/composite_0x2F62371D.ps_5_0.hlsl index 9a96f04bd..7ca7abca0 100644 --- a/src/games/deadspace/composite_0x2F62371D.ps_5_0.hlsl +++ b/src/games/deadspace/composite_0x2F62371D.ps_5_0.hlsl @@ -271,5 +271,11 @@ void main( o0.rgb /= 80.f; #endif +#if DEADSPACE_FORCE_HDR10 + o0.rgb = renodx::color::bt2020::from::BT709(o0.rgb); + o0.rgb *= 80.f; + o0.rgb = renodx::color::pq::EncodeSafe(o0.rgb, 1.f); +#endif + return; } diff --git a/src/games/deadspace/shared.h b/src/games/deadspace/shared.h index ceddf93bb..8ade7968c 100644 --- a/src/games/deadspace/shared.h +++ b/src/games/deadspace/shared.h @@ -1,29 +1,35 @@ #ifndef SRC_DEADSPACE2023_SHARED_H_ #define SRC_DEADSPACE2023_SHARED_H_ +#ifndef DEADSPACE_ENABLE_RESOURCE_UPGRADES #define DEADSPACE_ENABLE_RESOURCE_UPGRADES 1 +#endif + +#ifndef DEADSPACE_FORCE_HDR10 +#define DEADSPACE_FORCE_HDR10 0 +#endif struct ShaderInjectData { - float tone_map_type; - float tone_map_exposure; - float graphics_white_nits; - float override_ui_brightness; - float sdr_eotf_emulation_ui; - float custom_ui_visibility; - float peak_white_nits; - float override_game_brightness; - float diffuse_white_nits; - float tone_map_working_color_space; - float tone_map_highlights; - float tone_map_shadows; - float tone_map_contrast; - float tone_map_saturation; - float tone_map_highlight_saturation; - float tone_map_dechroma; - float tone_map_flare; - float custom_bloom; - float custom_grain_type; - float custom_grain_strength; + float tone_map_type; + float tone_map_exposure; + float graphics_white_nits; + float override_ui_brightness; + float sdr_eotf_emulation_ui; + float custom_ui_visibility; + float peak_white_nits; + float override_game_brightness; + float diffuse_white_nits; + float tone_map_working_color_space; + float tone_map_highlights; + float tone_map_shadows; + float tone_map_contrast; + float tone_map_saturation; + float tone_map_highlight_saturation; + float tone_map_dechroma; + float tone_map_flare; + float custom_bloom; + float custom_grain_type; + float custom_grain_strength; }; #ifndef __cplusplus @@ -33,7 +39,7 @@ cbuffer shader_injection : register(b13, space50) { #elif (__SHADER_TARGET_MAJOR < 5) || ((__SHADER_TARGET_MAJOR == 5) && (__SHADER_TARGET_MINOR < 1)) cbuffer shader_injection : register(b13) { #endif - ShaderInjectData shader_injection : packoffset(c0); + ShaderInjectData shader_injection : packoffset(c0); } // With OVERRIDE_GAME_BRIGHTNESS disabled @@ -46,17 +52,17 @@ cbuffer shader_injection : register(b13) { // 4432 = 2500 // 10081.5 = 4000 -#define TONE_MAP_TYPE shader_injection.tone_map_type -#define RENODX_TONE_MAP_EXPOSURE shader_injection.tone_map_exposure -#define RENODX_GRAPHICS_WHITE_NITS shader_injection.graphics_white_nits -#define RENODX_SDR_EOTF_EMULATION_UI shader_injection.sdr_eotf_emulation_ui -#define CUSTOM_SHOW_UI shader_injection.custom_ui_visibility +#define TONE_MAP_TYPE shader_injection.tone_map_type +#define RENODX_TONE_MAP_EXPOSURE shader_injection.tone_map_exposure +#define RENODX_GRAPHICS_WHITE_NITS shader_injection.graphics_white_nits +#define RENODX_SDR_EOTF_EMULATION_UI shader_injection.sdr_eotf_emulation_ui +#define CUSTOM_SHOW_UI shader_injection.custom_ui_visibility -#define RENODX_PEAK_WHITE_NITS shader_injection.peak_white_nits -#define OVERRIDE_GAME_BRIGHTNESS shader_injection.override_game_brightness -#define RENODX_DIFFUSE_WHITE_NITS shader_injection.diffuse_white_nits -#define OVERRIDE_UI_BRIGHTNESS shader_injection.override_ui_brightness -#define RENODX_TONE_MAP_WORKING_COLOR_SPACE shader_injection.tone_map_working_color_space // 0 - AP1, 1 - LMS +#define RENODX_PEAK_WHITE_NITS shader_injection.peak_white_nits +#define OVERRIDE_GAME_BRIGHTNESS shader_injection.override_game_brightness +#define RENODX_DIFFUSE_WHITE_NITS shader_injection.diffuse_white_nits +#define OVERRIDE_UI_BRIGHTNESS shader_injection.override_ui_brightness +#define RENODX_TONE_MAP_WORKING_COLOR_SPACE shader_injection.tone_map_working_color_space // 0 - AP1, 1 - LMS #define RENODX_TONE_MAP_HIGHLIGHTS shader_injection.tone_map_highlights #define RENODX_TONE_MAP_SHADOWS shader_injection.tone_map_shadows From 6bf86678481ef215cb3a150e6f80553b416fc19c Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Thu, 20 Aug 2026 23:41:36 -0400 Subject: [PATCH 16/22] feat(tlou2): add HDR mod for The Last of Us Part II Remastered --- .../CS_MovieRender_0x29445348.cs_6_0.hlslx | 53 + ..._PostPostProcessing_0x0998CC3E.cs_6_0.hlsl | 2409 +++++++++++++++++ ...S_PrePostProcessing_0x535F90FB.cs_6_0.hlsl | 927 +++++++ ...tToDisplayBufferHdr_0xAAEB4494.ps_6_0.hlsl | 48 + src/games/tlou2/addon.cpp | 258 ++ src/games/tlou2/common.hlsli | 140 + src/games/tlou2/metadata.json | 28 + src/games/tlou2/shared.h | 38 + 8 files changed, 3901 insertions(+) create mode 100644 src/games/tlou2/CS_MovieRender_0x29445348.cs_6_0.hlslx create mode 100644 src/games/tlou2/CS_PostPostProcessing_0x0998CC3E.cs_6_0.hlsl create mode 100644 src/games/tlou2/CS_PrePostProcessing_0x535F90FB.cs_6_0.hlsl create mode 100644 src/games/tlou2/PS_OutputToDisplayBufferHdr_0xAAEB4494.ps_6_0.hlsl create mode 100644 src/games/tlou2/addon.cpp create mode 100644 src/games/tlou2/common.hlsli create mode 100644 src/games/tlou2/metadata.json create mode 100644 src/games/tlou2/shared.h diff --git a/src/games/tlou2/CS_MovieRender_0x29445348.cs_6_0.hlslx b/src/games/tlou2/CS_MovieRender_0x29445348.cs_6_0.hlslx new file mode 100644 index 000000000..a10468828 --- /dev/null +++ b/src/games/tlou2/CS_MovieRender_0x29445348.cs_6_0.hlslx @@ -0,0 +1,53 @@ +#include "./common.hlsli" + +struct MovieRenderParams { + int MovieRenderParams_000; + int MovieRenderParams_004; + int MovieRenderParams_008; + int MovieRenderParams_012; + int MovieRenderParams_016; + int MovieRenderParams_020; +}; + +Texture2D t0 : register(t0); + +Texture2D t1 : register(t1); + +Texture2D t2 : register(t2); + +RWTexture2D u0 : register(u0); + +cbuffer cb0 : register(b0) { + MovieRenderParams g_movieRenderConstants_000 : packoffset(c000.x); +}; + +SamplerState s0 : register(s0); + +[numthreads(32, 2, 1)] +void main( + uint3 SV_DispatchThreadID: SV_DispatchThreadID, + uint3 SV_GroupID: SV_GroupID, + uint3 SV_GroupThreadID: SV_GroupThreadID, + uint SV_GroupIndex: SV_GroupIndex) { + float _20; + float _22; + float _35; + float _36; + float4 _37; + float _41; + float _44; + if ((uint)(int)(SV_DispatchThreadID.x) < (uint)g_movieRenderConstants_000.MovieRenderParams_016) { + if ((uint)(int)(SV_DispatchThreadID.y) < (uint)g_movieRenderConstants_000.MovieRenderParams_020) { + _20 = float((int)(g_movieRenderConstants_000.MovieRenderParams_008)); + _22 = float((int)(g_movieRenderConstants_000.MovieRenderParams_012)); + _35 = (0.5f / _20) + ((1.0f / _20) * ((_20 * ((float)((uint)SV_DispatchThreadID.x))) / float((int)(g_movieRenderConstants_000.MovieRenderParams_016)))); + _36 = (0.5f / _22) + ((1.0f / _22) * ((_22 * ((float)((uint)SV_DispatchThreadID.y))) / float((int)(g_movieRenderConstants_000.MovieRenderParams_020)))); + _37 = t0.SampleLevel(s0, float2(_35, _36), 0.0f); + _41 = (((float4)(t2.SampleLevel(s0, float2(_35, _36), 0.0f))).x) + -0.5019599795341492f; + _44 = (((float4)(t1.SampleLevel(s0, float2(_35, _36), 0.0f))).x) + -0.5019599795341492f; + u0[int2(((int)((uint)(g_movieRenderConstants_000.MovieRenderParams_000) + SV_DispatchThreadID.x)), ((int)((uint)(g_movieRenderConstants_000.MovieRenderParams_004) + SV_DispatchThreadID.y)))] = float4(saturate(dot(float3(1.0f, 0.0f, 1.4019999504089355f), float3(_37.x, _41, _44))), + saturate(dot(float3(1.0f, -0.3440999984741211f, -0.7141000032424927f), float3(_37.x, _41, _44))), + saturate(dot(float3(1.0f, 1.7719999551773071f, 0.0f), float3(_37.x, _41, _44))), 1.0f); + } + } +} diff --git a/src/games/tlou2/CS_PostPostProcessing_0x0998CC3E.cs_6_0.hlsl b/src/games/tlou2/CS_PostPostProcessing_0x0998CC3E.cs_6_0.hlsl new file mode 100644 index 000000000..207ccf41c --- /dev/null +++ b/src/games/tlou2/CS_PostPostProcessing_0x0998CC3E.cs_6_0.hlsl @@ -0,0 +1,2409 @@ +#include "./common.hlsli" + +struct PostProcessingShaderConst { + float4 PostProcessingShaderConst_000[4]; + float4 PostProcessingShaderConst_064[4]; + float4 PostProcessingShaderConst_128; + float4 PostProcessingShaderConst_144; + float4 PostProcessingShaderConst_160; + float4 PostProcessingShaderConst_176; + float4 PostProcessingShaderConst_192; + float4 PostProcessingShaderConst_208; + float4 PostProcessingShaderConst_224; + float4 PostProcessingShaderConst_240; + float4 PostProcessingShaderConst_256; + float4 PostProcessingShaderConst_272; + float4 PostProcessingShaderConst_288; + float4 PostProcessingShaderConst_304; + float4 PostProcessingShaderConst_320; + float4 PostProcessingShaderConst_336; + float4 PostProcessingShaderConst_352; + float4 PostProcessingShaderConst_368; + float4 PostProcessingShaderConst_384; + float4 PostProcessingShaderConst_400; + float4 PostProcessingShaderConst_416; + float4 PostProcessingShaderConst_432; + float4 PostProcessingShaderConst_448; + float4 PostProcessingShaderConst_464; + float4 PostProcessingShaderConst_480; + float4 PostProcessingShaderConst_496; + int PostProcessingShaderConst_512; + int PostProcessingShaderConst_516; + float2 PostProcessingShaderConst_520; + float2 PostProcessingShaderConst_528; + float2 PostProcessingShaderConst_536; + float2 PostProcessingShaderConst_544; + float2 PostProcessingShaderConst_552; + float2 PostProcessingShaderConst_560; + float2 PostProcessingShaderConst_568; + float2 PostProcessingShaderConst_576; + float PostProcessingShaderConst_584; + float PostProcessingShaderConst_588; + float PostProcessingShaderConst_592; + float PostProcessingShaderConst_596; + float PostProcessingShaderConst_600; + float PostProcessingShaderConst_604; + float PostProcessingShaderConst_608; + float PostProcessingShaderConst_612; + float PostProcessingShaderConst_616; + int PostProcessingShaderConst_620; + int PostProcessingShaderConst_624; + int PostProcessingShaderConst_628; + int PostProcessingShaderConst_632; + int PostProcessingShaderConst_636; + float PostProcessingShaderConst_640; + float PostProcessingShaderConst_644; + float PostProcessingShaderConst_648; + float PostProcessingShaderConst_652; + float PostProcessingShaderConst_656; + float PostProcessingShaderConst_660; + float PostProcessingShaderConst_664; + float PostProcessingShaderConst_668; + float PostProcessingShaderConst_672; + float PostProcessingShaderConst_676; + int PostProcessingShaderConst_680; + float PostProcessingShaderConst_684; + float PostProcessingShaderConst_688; + int PostProcessingShaderConst_692; + float PostProcessingShaderConst_696; + float PostProcessingShaderConst_700; + int PostProcessingShaderConst_704; + float PostProcessingShaderConst_708; + float PostProcessingShaderConst_712; + int PostProcessingShaderConst_716; + int PostProcessingShaderConst_720; + float PostProcessingShaderConst_724; + float PostProcessingShaderConst_728; + float PostProcessingShaderConst_732; + float PostProcessingShaderConst_736; + float PostProcessingShaderConst_740; + float PostProcessingShaderConst_744; + float PostProcessingShaderConst_748; + float PostProcessingShaderConst_752; + float PostProcessingShaderConst_756; + float PostProcessingShaderConst_760; + int PostProcessingShaderConst_764; + int PostProcessingShaderConst_768; + int PostProcessingShaderConst_772; + float PostProcessingShaderConst_776; + float PostProcessingShaderConst_780; + float PostProcessingShaderConst_784; + float PostProcessingShaderConst_788; + float PostProcessingShaderConst_792; + float PostProcessingShaderConst_796; + float PostProcessingShaderConst_800; + float PostProcessingShaderConst_804; + int PostProcessingShaderConst_808; + float PostProcessingShaderConst_812; + float PostProcessingShaderConst_816; + float PostProcessingShaderConst_820; + int PostProcessingShaderConst_824; + int PostProcessingShaderConst_828; + float PostProcessingShaderConst_832; + float PostProcessingShaderConst_836; + float PostProcessingShaderConst_840; + float PostProcessingShaderConst_844; + int PostProcessingShaderConst_848; + int PostProcessingShaderConst_852; + int PostProcessingShaderConst_856; + float PostProcessingShaderConst_860; + float PostProcessingShaderConst_864; + float PostProcessingShaderConst_868; + float PostProcessingShaderConst_872; + float PostProcessingShaderConst_876; +}; + +Texture2D t0 : register(t0); + +Texture2D t2 : register(t2); + +Texture2D t3 : register(t3); + +Texture2D t7 : register(t7); + +Texture2D t8 : register(t8); + +Texture2D t9 : register(t9); + +Texture2D t10 : register(t10); + +Texture2D t11 : register(t11); + +Texture2D t12 : register(t12); + +Texture3D t17 : register(t17); + +RWTexture2D u0 : register(u0); + +cbuffer cb0 : register(b0) { + PostProcessingShaderConst g_postPostProcessingShaderConst_000 : packoffset(c000.x); +}; + +SamplerState s1 : register(s1); + +SamplerState s2 : register(s2); + +// Reverse-engineering notes (inferred from data flow; original symbol names are unavailable): +// t0 Main post-processed color input. It is sampled at the warped scene UV and by +// the optional edge-blur modes. +// t2 Screen-space offset/distortion field. Its XY channels perturb the scene UV. +// t3 Film-grain/noise texture. +// t7 Auxiliary full-screen image used by global blends, wipes, and an inset overlay. +// t8/t9 Color plus scalar/depth-like mask for the first depth-aware overlay. +// t10 Scalar contribution for the second optional overlay. +// t11/t12 Color plus scalar/depth-like mask for the second depth-aware overlay. +// t17 3D color-grading LUT applied to the auxiliary-image path. +// u0 Final post-post-process output. +// +// High-level order of operations: +// 1. Build output UV and reject pixels outside the requested presentation aspect. +// 2. Read t2 and construct a distorted source UV. +// 3. Optionally apply a localized radial lens/scope warp. +// 4. Sample t0, including channel-separated samples for chromatic aberration. +// 5. Apply two optional per-channel contrast/color curves. +// 6. Add two optional depth-aware color/overlay contributions. +// 7. Apply a radial tint/vignette and localized lens attenuation. +// 8. Blend/wipe t7, optionally grade it through t17, and draw an optional inset. +// 9. Add signal-dependent film grain from t3. +// 10. Apply final RGB scale/bias and optional edge blur/fill/fade behavior. +// 11. Clamp negative RGB values and write u0 with alpha zero. +// +// The decompiler duplicated the same per-pixel pipeline for multiple aspect-ratio branches. +// The first copy below is annotated in detail; later copies perform the same stages. +static const float _global_0[8] = { -0.7071067690849304f, -0.7071067690849304f, 0.7071067690849304f, 0.7071067690849304f, 1.0f, -1.0f, 0.0f, 0.0f }; +static const float _global_1[8] = { -0.7071067690849304f, 0.7071067690849304f, -0.7071067690849304f, 0.7071067690849304f, 0.0f, 0.0f, 1.0f, -1.0f }; + +float3 ApplyVanillaFilmGrain(float3 color, float2 output_uv) { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_464.x == 0.0f) { + return color; + } + + if (CUSTOM_GRAIN_TYPE != 0.f && RENODX_TONE_MAP_TYPE != 0.f) { + return color; + } + + bool use_horizontal_position = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_480.y > 0.0f; + float3 grain_signal = float3( + select(use_horizontal_position, output_uv.x, color.x), + select(use_horizontal_position, output_uv.x, color.y), + select(use_horizontal_position, output_uv.x, color.z)); + float3 grain = t3.SampleLevel( + s1, + float2( + (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_448.x * output_uv.x) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_448.z, + (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_448.y * output_uv.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_448.w), + 0.0f) + - 0.5f; + float3 saturated_signal = saturate(grain_signal); + + grain *= g_postPostProcessingShaderConst_000.PostProcessingShaderConst_464.x * 0.30000001192092896f; + grain *= (saturated_signal * (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_464.z - 1.0f)) + 1.0f; + grain *= (saturated_signal * (1.0f - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_464.y)) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_464.y; + grain *= ((1.0f - ((saturated_signal * 4.0f) * (1.0f - saturated_signal))) * (1.0f - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_480.x)) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_480.x; + grain *= sqrt(saturate((grain_signal * 4.0f) * (1.0f - grain_signal))); + return grain + grain_signal; +} + +[numthreads(8, 8, 1)] +void main( + uint3 SV_DispatchThreadID: SV_DispatchThreadID, + uint3 SV_GroupID: SV_GroupID, + uint3 SV_GroupThreadID: SV_GroupThreadID, + uint SV_GroupIndex: SV_GroupIndex) { + float _20; + float _21; + float _25; + float _33; + bool _45; + bool _57; + float _89; + bool _115; + float _166; + float _167; + float _168; + float _169; + float _170; + float _171; + int _172; + float _204; + float _244; + float _245; + float _297; + float _298; + float _299; + float _323; + float _324; + float _325; + float _384; + float _385; + float _386; + int _387; + float _421; + float _429; + float _430; + float _431; + float _436; + float _437; + float _438; + float _457; + float _458; + float _459; + int _460; + float _494; + float _495; + float _496; + float _541; + float _542; + float _543; + float _556; + float _557; + float _558; + float _578; + float _579; + float _580; + float _614; + float _615; + float _616; + float _653; + float _654; + float _655; + float _720; + float _721; + float _722; + float _823; + float _824; + float _825; + int _901; + float _902; + float _903; + float _904; + float _905; + float _906; + float _907; + float _988; + float _989; + float _990; + float _991; + float _992; + float _993; + int _994; + float _1057; + float _1058; + float _1059; + bool _64; + bool _66; + float _81; + float _83; + float _90; + float _97; + float _108; + float4 _118; + float _125; + float _126; + float _133; + float _135; + float _139; + float _143; + float _144; + float _145; + float _173; + float _175; + float _177; + float _181; + float _183; + float _186; + float _188; + float _192; + float _193; + float4 _194; + float _207; + float _214; + float _215; + float _217; + float _224; + float _225; + float _232; + float4 _233; + float4 _238; + float _248; + float _249; + float _250; + float _252; + float _255; + float _268; + float _337; + float _347; + float _348; + float4 _362; + float _370; + float _371; + float _379; + float _380; + float _399; + int _400; + float _404; + float _406; + float _408; + float _445; + float3 _449; + float _452; + float _453; + float _472; + int _473; + float _477; + float _479; + float _484; + float _486; + float _520; + float _521; + float _528; + float _533; + float _551; + float3 _564; + float3 _588; + float _593; + float _596; + float _597; + float _603; + float4 _639; + int _665; + int _666; + uint2 _667; + float _673; + float _692; + float _694; + float _695; + float3 _697; + float3 _703; + float _709; + float3 _745; + float _837; + float _838; + float _839; + float _852; + float _859; + float _866; + uint _870; + int _875; + int _876; + float _894; + float _898; + float _909; + float4 _916; + float _920; + float _921; + float _922; + uint _930; + float _936; + float _950; + uint _954; + int _959; + int _960; + float _978; + float _985; + float _996; + float _1007; + float4 _1016; + float _1020; + float _1021; + float _1022; + uint _1030; + float _1036; + float _1049; + // Step 1: convert the dispatch pixel to output-space coordinates and normalized UV. + // Const_536 is the output texel size. Const_764/768 select and offset a horizontal view. + _20 = float((int)((int)(SV_DispatchThreadID.x))); + _21 = float((int)((int)(SV_DispatchThreadID.y))); + _25 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y * (_21 + 0.5f); + _33 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x * (_20 + 0.5f)) * float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_764))) + float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_768)); + // Feature gates: localized lens effect, radial tint, and two t7 compositing modes. + _45 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792 > 0.0f); + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.x > 0.5f) { + _57 = (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.w == 0.0f)); + } else { + _57 = false; + } + _64 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612 > 0.0f); + _66 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_616 > 0.0f); + // Step 2: presentation/aspect handling. For targets wider than 16:9, compute a + // centered 16:9-height region and clear pixels in its top/bottom bars. + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_816 > 1.7777777910232544f) { + _81 = (1.7777777910232544f / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_816) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_560.y; + _83 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_560.y - _81) * 0.5f; + if ((_21 < _83) || (_21 > (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_560.y - _83))) { + _115 = true; + do { + if (_115) { + u0[int2((int)(SV_DispatchThreadID.x), (int)(SV_DispatchThreadID.y))] = float4(0.0f, 0.0f, 0.0f, 0.0f); + } else { + // Step 3: t2.xy is a sub-pixel distortion/offset field. Convert it through + // the output texel size and offset the base UV before sampling the scene. + _118 = t2.SampleLevel(s2, float2(_33, _25), 0.0f); + _125 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x * 1.5f) * _118.x) + _33; + _126 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y * 1.5f) * _118.y) + _25; + do { + _166 = 0.5f; + _167 = 0.5f; + _168 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_592; + _169 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_596; + _170 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600; + _171 = 1.0f; + _172 = 0; + // Step 4: localized radial lens/scope deformation. Inside Const_796's + // radius, blend center, barrel coefficient, zoom, and chromatic offset. + if (_45) { + _133 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.y - _126; + _135 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.x - _125) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_800; + _139 = sqrt((_135 * _135) + (_133 * _133)); + if (_139 < g_postPostProcessingShaderConst_000.PostProcessingShaderConst_796) { + _143 = _139 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_796; + _144 = saturate(_143); + _145 = _144 * _144; + _166 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.x; + _167 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.y; + _168 = (((((_145 * _145) * (_144 * 1.5f)) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_592) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_592); + _169 = (lerp(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_596, 0.9200000166893005f, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792)); + _170 = ((((_143 * 0.02250000089406967f) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600); + _171 = (((_143 * (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_608 + -1.0f)) + 1.0f) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_604); + _172 = 1; + } else { + _166 = 0.5f; + _167 = 0.5f; + _168 = -0.03500000014901161f; + _169 = 1.0f; + _170 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600; + _171 = 1.0f; + _172 = 0; + } + } + _173 = _125 - _166; + _175 = (_126 - _167) * 0.5625f; + _177 = dot(float2(_173, _175), float2(_173, _175)) * _168; + _181 = (_125 + -0.5f) + (_177 * _173); + _183 = (_126 + -0.5f) + (_177 * _175); + _186 = 0.5f - _166; + _188 = 0.5f - _167; + _192 = (((_181 * _169) + _186) / _171) + _166; + _193 = (((_183 * _169) + _188) / _171) + _167; + // Step 5: center sample of the main color buffer at the warped UV. + _194 = t0.SampleLevel(s2, float2(_192, _193), 0.0f); + do { + _204 = _170; + if (_45) { + _204 = (saturate(dot(float3(_194.x, _194.y, _194.z), float3(0.30000001192092896f, 0.5899999737739563f, 0.10999999940395355f)) * 100.0f) * _170); + } + do { + _244 = _194.y; + _245 = _194.z; + // Step 6: chromatic aberration. Keep red from the center sample, + // sample green at a moderate radial offset, and blue farther out. + if (_45 || (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600 == 0.0f))) { + _207 = _169 - (_204 * 0.6000000238418579f); + _214 = (((_207 * _181) + _186) / _171) + _166; + _215 = (((_207 * _183) + _188) / _171) + _167; + _217 = _169 - (_204 * 2.0f); + _224 = (((_217 * _181) + _186) / _171) + _166; + _225 = (((_217 * _183) + _188) / _171) + _167; + if (!(_45)) { + _244 = (((float4)(t0.SampleLevel(s2, float2(_214, _215), 0.0f))).y); + _245 = (((float4)(t0.SampleLevel(s2, float2(_224, _225), 0.0f))).z); + } else { + _232 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792 * 0.25f; + _233 = t0.SampleLevel(s2, float2(_214, _215), 0.0f); + _238 = t0.SampleLevel(s2, float2(_224, _225), 0.0f); + _244 = (lerp(_233.y, _194.y, _232)); + _245 = (lerp(_238.z, _194.z, _232)); + } + } + do { + _556 = _194.x; + _557 = _244; + _558 = _245; + // Step 7: the low three bits of Const_512 gate the main color-adjustment chain. + if ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_512 & 7) == 7) { + do { + _297 = _194.x; + _298 = _244; + _299 = _245; + // Step 7a: luminance-guided soft-light/contrast operation. Work in + // squared color, blend toward an overlay-style curve, then sqrt back. + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584 > 0.0f) { + _248 = _194.x * _194.x; + _249 = _244 * _244; + _250 = _245 * _245; + _252 = saturate(dot(float3(_248, _249, _250), float3(0.30000001192092896f, 0.5899999737739563f, 0.10999999940395355f))); + _255 = _252 + 0.5f; + _268 = 0.5f - ((_252 + -0.5f) * 0.5f); + _297 = sqrt(((saturate(select((_248 > 0.5f), (1.0f - (_268 * (1.0f - ((_248 + -0.5f) * 2.0f)))), (_255 * _248))) - _248) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584) + _248); + _298 = sqrt(((saturate(select((_249 > 0.5f), (1.0f - (_268 * (1.0f - ((_249 + -0.5f) * 2.0f)))), (_255 * _249))) - _249) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584) + _249); + _299 = sqrt(((saturate(select((_250 > 0.5f), (1.0f - (_268 * (1.0f - ((_250 + -0.5f) * 2.0f)))), (_255 * _250))) - _250) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584) + _250); + } + do { + _323 = _297; + _324 = _298; + _325 = _299; + // Step 7b: optional per-channel cubic contrast curve, clamped to SDR range. + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812 > 0.0f) { + _323 = saturate((((((_297 * _297) * 6.0f) * _297) - _297) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812) + _297); + _324 = saturate((((((_298 * _298) * 6.0f) * _298) - _298) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812) + _298); + _325 = saturate((((((_299 * _299) * 6.0f) * _299) - _299) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812) + _299); + } + do { + _436 = _323; + _437 = _324; + _438 = _325; + // Step 8a: optional depth-aware overlay B. t10 supplies a scalar + // base; t11 supplies RGB/mask data; t12 drives an 8-neighbor + // discontinuity search before the contribution is added. + if (!((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_516 & 2) == 0)) { + if (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_852 == 9)) { + _337 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_648 * (((float4)(t10.SampleLevel(s2, float2(_192, _193), 0.0f))).x)) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_652; + do { + _429 = _337; + _430 = _337; + _431 = _337; + if (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_680 == 0)) { + _347 = _20 - (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_544.x * 0.5f); + _348 = _21 - (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_544.y * 0.5f); + _362 = t11.SampleLevel(s2, float2(_192, _193), 0.0f); + _370 = max(((1.0f - saturate(((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_660 * sqrt((_347 * _347) + (_348 * _348))) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_664) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_668)) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_648), _362.w) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_656; + _371 = t12.SampleLevel(s2, float2(_192, _193), 0.0f); + do { + _421 = _370; + if (!(_371.x == 1.0f)) { + _379 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (_371.x - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x); + _380 = sqrt(_379); + _384 = -0.7071067690849304f; + _385 = -0.7071067690849304f; + _386 = 0.0f; + _387 = 0; + bool _loop_break_0 = false; + while (true) { + _399 = max(((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (((t12.SampleLevel(s2, float2(saturate((_385 * (0.004166666883975267f / _380)) + _192), saturate((_384 * (0.007407407276332378f / _380)) + _193)), 0.0f)).x) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x)) - _379), _386); + _400 = _387 + 1; + if (!(_400 == 8)) { + _404 = _global_0[min((uint)(_400), 7u)]; + _406 = _global_1[min((uint)(_400), 7u)]; + _384 = _406; + _385 = _404; + _386 = _399; + _387 = _400; + _loop_break_0 = true; + break; + } else { + _408 = min(_399, 0.10000000149011612f); + _421 = (((_408 * _408) * _370) * max((1.0f - saturate((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_672 * _379) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_676)), _362.w)); + } + break; + } + } + _429 = ((_421 * _362.x) + _337); + _430 = ((_421 * _362.y) + _337); + _431 = ((_421 * _362.z) + _337); + } while (false); + } + _436 = (_429 + _323); + _437 = (_430 + _324); + _438 = (_431 + _325); + } while (false); + } else { + _436 = _323; + _437 = _324; + _438 = _325; + } + } + do { + _494 = _436; + _495 = _437; + _496 = _438; + // Step 8b: optional depth-aware overlay A. t8 supplies RGB and + // t9 supplies the scalar/depth-like field used by the same + // 8-neighbor discontinuity search. + if (!((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_516 & 1) == 0)) { + _445 = t9.SampleLevel(s2, float2(_192, _193), 0.0f); + if (!(_445.x == 1.0f)) { + _449 = t8.SampleLevel(s2, float2(_192, _193), 0.0f); + _452 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (_445.x - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x); + _453 = sqrt(_452); + _457 = -0.7071067690849304f; + _458 = -0.7071067690849304f; + _459 = 0.0f; + _460 = 0; + bool _loop_break_1 = false; + while (true) { + _472 = max(((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (((t9.SampleLevel(s2, float2(saturate((_458 * (0.004166666883975267f / _453)) + _192), saturate((_457 * (0.007407407276332378f / _453)) + _193)), 0.0f)).x) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x)) - _452), _459); + _473 = _460 + 1; + if (!(_473 == 8)) { + _477 = _global_0[min((uint)(_473), 7u)]; + _479 = _global_1[min((uint)(_473), 7u)]; + _457 = _479; + _458 = _477; + _459 = _472; + _460 = _473; + _loop_break_1 = true; + break; + } else { + _484 = min(max(_472, 0.0f), 0.10000000149011612f); + _486 = (_484 * _484) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_640; + _494 = ((_486 * _449.x) + _436); + _495 = ((_486 * _449.y) + _437); + _496 = ((_486 * _449.z) + _438); + } + break; + } + } else { + _494 = _436; + _495 = _437; + _496 = _438; + } + } + do { + _541 = _494; + _542 = _495; + _543 = _496; + // Step 9: radial tint/vignette. Build an elliptical distance + // mask and smoothly blend Const_416.rgb toward the current color. + if (_57) { + _520 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.x * _192) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.z) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.z; + _521 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.y * _193) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.w) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.w; + _528 = saturate(saturate((1.0f - (sqrt(dot(float2(_520, _521), float2(_520, _521))) * 2.0f)) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.y)); + _533 = 1.0f - ((_528 * _528) * (3.0f - (_528 * 2.0f))); + _541 = (((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.x * (1.0f - _494)) * _533) + _494); + _542 = (((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.y * (1.0f - _495)) * _533) + _495); + _543 = (((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.z * (1.0f - _496)) * _533) + _496); + } + // Additional attenuation outside the active localized lens region. + if (!((_172 != 0) || (!_45))) { + _551 = 1.0f - ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792 * 0.1499999761581421f) * saturate(_125)); + _556 = (_551 * _541); + _557 = (_551 * _542); + _558 = (_551 * _543); + } else { + _556 = _541; + _557 = _542; + _558 = _543; + } + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } + do { + _578 = _556; + _579 = _557; + _580 = _558; + // Step 10a: globally blend the auxiliary image t7 into the current color. + if (_64) { + _564 = t7.SampleLevel(s2, float2((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x * _33), (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y * _25)), 0.0f); + _578 = (lerp(_556, _564.x, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612)); + _579 = (lerp(_557, _564.y, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612)); + _580 = (lerp(_558, _564.z, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612)); + } + do { + _614 = _578; + _615 = _579; + _616 = _580; + // Step 10b: directional horizontal wipe to t7. Const_620 selects + // left-to-right versus right-to-left and a narrow band softens the edge. + if (_66) { + _588 = t7.SampleLevel(s2, float2((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x * _33), (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y * _25)), 0.0f); + _593 = (1.0f - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_616) * 1.0999999046325684f; + _596 = select((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_620 == 0), _33, (1.0f - _33)); + _597 = _593 + -0.10000000149011612f; + if (!(_596 < _597)) { + if (!(_596 > _593)) { + _603 = (_596 - _597) * 10.0f; + _614 = (lerp(_578, _588.x, _603)); + _615 = (lerp(_579, _588.y, _603)); + _616 = (lerp(_580, _588.z, _603)); + } else { + _614 = _588.x; + _615 = _588.y; + _616 = _588.z; + } + } else { + _614 = _578; + _615 = _579; + _616 = _580; + } + } + do { + _653 = _614; + _654 = _615; + _655 = _616; + // Step 10c: when requested, map the bounded t7-blended color into + // the t17 3D LUT domain and blend the graded result by Const_684. + if ((_64 || _66) && (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_856 != 0)) { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684 > 0.0f) { + _639 = t17.SampleLevel(s2, float3(((saturate(_614) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.z), ((saturate(_615) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.z), ((saturate(_616) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.z)), 0.0f); + _653 = (lerp(_614, _639.x, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684)); + _654 = (lerp(_615, _639.y, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684)); + _655 = (lerp(_616, _639.z, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684)); + } else { + _653 = _614; + _654 = _615; + _655 = _616; + } + } + do { + _720 = _653; + _721 = _654; + _722 = _655; + // Step 10d: optional diagnostic/comparison inset. Two vertically + // stacked regions of t7 provide inset RGB and a grayscale blend mask. + if (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_624 == 0)) { + _665 = int(float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_632)) / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x); + _666 = int(float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_636)) / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y); + uint2 _667; + t7.GetDimensions(_667.x, _667.y); + if (!((int)(int)(SV_DispatchThreadID.x) < (int)_665)) { + _673 = float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_628)); + if (((int)(int)(SV_DispatchThreadID.y) < (int)(int(_673 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y) + _666)) && (((int)(int)(SV_DispatchThreadID.y) >= (int)_666) && ((int)(int)(SV_DispatchThreadID.x) < (int)(int(float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_624)) / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x) + _665)))) { + _692 = float((int)((int)(SV_DispatchThreadID.y - _666))) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y; + _694 = (float)((uint)_667.y); + _695 = (float((int)((int)(SV_DispatchThreadID.x - _665))) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x) / ((float)((uint)_667.x)); + _697 = t7.SampleLevel(s2, float2(_695, (_692 / _694)), 0.0f); + _703 = t7.SampleLevel(s2, float2(_695, ((_692 + _673) / _694)), 0.0f); + _709 = ((_703.x + _703.y) + _703.z) * 0.3333333432674408f; + _720 = ((_709 * (_697.x - _653)) + _653); + _721 = ((_709 * (_697.y - _654)) + _654); + _722 = ((_709 * (_697.z - _655)) + _655); + } else { + _720 = _653; + _721 = _654; + _722 = _655; + } + } else { + _720 = _653; + _721 = _654; + _722 = _655; + } + } + do { + // Step 11: signal-dependent film grain. t3 is remapped around + // 0.5, then shaped by channel value, toe/highlight response, + // and the configured grain intensity before being added. + _745 = ApplyVanillaFilmGrain(float3(_720, _721, _722), float2(_33, _25)); + _823 = _745.x; + _824 = _745.y; + _825 = _745.z; + // Step 12: final channel-wise output scale and bias. + _837 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_336.x * _823) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_352.x; + _838 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_336.y * _824) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_352.y; + _839 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_336.z * _825) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_352.z; + do { + _1057 = _837; + _1058 = _838; + _1059 = _839; + // Step 13: optional horizontal edge treatment. Const_832/836 + // define the inner/outer transition and Const_848 selects mode: + // 1 = stochastic radial blur around the current source UV, + // 2 = stochastic blur of a clamped/reprojected central region, + // 3 = fade the affected edge region to black. + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832 < 1.0f) { + if (sqrt(((_837 * _837) + (_838 * _838)) + (_839 * _839)) > 0.0f) { + _852 = _192 + -0.5f; + _859 = saturate(((abs(_852) * 2.0f) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832) / (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_836 - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832)); + if (_859 > 0.0f) { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_848 == 1) { + _866 = _859 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_840; + _870 = uint(ceil(_866 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_844)); + if (!(_870 == 0)) { + _875 = int(_192 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x); + _876 = int(_193 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y); + _894 = ((float((int)(((_876 + _875) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_828) & 1)) * 0.10000000149011612f) + frac((((float((int)(_876)) * 2.0f) + float((int)(_875))) + ((float)((uint)(uint)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_824)))) * 0.20000000298023224f)) * 6.2831854820251465f; + _898 = 1.0f / ((float)((uint)_870)); + _901 = 0; + _902 = 0.0f; + _903 = 0.0f; + _904 = 0.0f; + _905 = (_898 * 0.5f); + _906 = cos(_894); + _907 = sin(_894); + bool _loop_break_2 = false; + while (true) { + _909 = sqrt(_905) * _866; + _916 = t0.SampleLevel(s2, float2((((_906 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x) * _909) + _192), (((_907 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y) * _909) + _193)), 0.0f); + _920 = _916.x + _902; + _921 = _916.y + _903; + _922 = _916.z + _904; + _930 = _901 + 1u; + do { + if (!(_930 == _870)) { + _901 = _930; + _902 = _920; + _903 = _921; + _904 = _922; + _905 = (_905 + _898); + _906 = ((_906 * -0.7373688220977783f) - (_907 * 0.6754903793334961f)); + _907 = ((_906 * 0.6754903793334961f) - (_907 * 0.7373688220977783f)); + _loop_break_2 = true; + break; + } + _936 = saturate(_866); + _1057 = ((_936 * ((_920 * _898) - _837)) + _837); + _1058 = ((_936 * ((_921 * _898) - _838)) + _838); + _1059 = ((_936 * ((_922 * _898) - _839)) + _839); + } while (false); + if (_loop_break_2) { + _loop_break_2 = false; + continue; + } + break; + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } else { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_848 == 2) { + _950 = _859 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_840; + _954 = uint(ceil(_950 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_844)); + if (!(_954 == 0)) { + _959 = int(_192 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x); + _960 = int(_193 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y); + _978 = ((float((int)(((_960 + _959) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_828) & 1)) * 0.10000000149011612f) + frac((((float((int)(_960)) * 2.0f) + float((int)(_959))) + ((float)((uint)(uint)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_824)))) * 0.20000000298023224f)) * 6.2831854820251465f; + _985 = 1.0f / ((float)((uint)_954)); + _988 = cos(_978); + _989 = sin(_978); + _990 = (_985 * 0.5f); + _991 = 0.0f; + _992 = 0.0f; + _993 = 0.0f; + _994 = 0; + bool _loop_break_3 = false; + while (true) { + _996 = sqrt(_990) * _950; + _1007 = -0.0f - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832; + _1016 = t0.SampleLevel(s2, float2(((min(max(((((_988 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x) * _996) + (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832 * _852)) * 2.0f), _1007), g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832) * 0.5f) + 0.5f), ((min(max(((((_989 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y) * _996) + (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832 * (_193 + -0.5f))) * 2.0f), _1007), g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832) * 0.5f) + 0.5f)), 0.0f); + _1020 = _1016.x + _991; + _1021 = _1016.y + _992; + _1022 = _1016.z + _993; + _1030 = _994 + 1u; + do { + if (!(_1030 == _954)) { + _988 = ((_988 * -0.7373688220977783f) - (_989 * 0.6754903793334961f)); + _989 = ((_988 * 0.6754903793334961f) - (_989 * 0.7373688220977783f)); + _990 = (_990 + _985); + _991 = _1020; + _992 = _1021; + _993 = _1022; + _994 = _1030; + _loop_break_3 = true; + break; + } + _1036 = saturate(_859); + _1057 = ((_1036 * ((_1020 * _985) - _837)) + _837); + _1058 = ((_1036 * ((_1021 * _985) - _838)) + _838); + _1059 = ((_1036 * ((_1022 * _985) - _839)) + _839); + } while (false); + if (_loop_break_3) { + _loop_break_3 = false; + continue; + } + break; + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } else { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_848 == 3) { + _1049 = saturate(_859); + _1057 = (_837 - (_1049 * _837)); + _1058 = (_838 - (_1049 * _838)); + _1059 = (_839 - (_1049 * _839)); + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } + // Step 14: preserve positive HDR values, reject negative RGB, + // and write alpha zero as expected by this intermediate. + u0[int2((int)(SV_DispatchThreadID.x), (int)(SV_DispatchThreadID.y))] = float4(max(_1057, 0.0f), max(_1058, 0.0f), max(_1059, 0.0f), 0.0f); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } + } while (false); + } else { + _89 = _81; + } + } else { + _89 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_560.y; + } + _90 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_560.x / _89; + if (_90 > g_postPostProcessingShaderConst_000.PostProcessingShaderConst_820) { + _97 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_560.x - ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_820 / _90) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_560.x)) * 0.5f; + if ((_20 < _97) || (_20 > (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_560.x - _97))) { + _115 = true; + do { + if (_115) { + u0[int2((int)(SV_DispatchThreadID.x), (int)(SV_DispatchThreadID.y))] = float4(0.0f, 0.0f, 0.0f, 0.0f); + } else { + _118 = t2.SampleLevel(s2, float2(_33, _25), 0.0f); + _125 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x * 1.5f) * _118.x) + _33; + _126 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y * 1.5f) * _118.y) + _25; + do { + _166 = 0.5f; + _167 = 0.5f; + _168 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_592; + _169 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_596; + _170 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600; + _171 = 1.0f; + _172 = 0; + if (_45) { + _133 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.y - _126; + _135 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.x - _125) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_800; + _139 = sqrt((_135 * _135) + (_133 * _133)); + if (_139 < g_postPostProcessingShaderConst_000.PostProcessingShaderConst_796) { + _143 = _139 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_796; + _144 = saturate(_143); + _145 = _144 * _144; + _166 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.x; + _167 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.y; + _168 = (((((_145 * _145) * (_144 * 1.5f)) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_592) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_592); + _169 = (lerp(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_596, 0.9200000166893005f, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792)); + _170 = ((((_143 * 0.02250000089406967f) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600); + _171 = (((_143 * (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_608 + -1.0f)) + 1.0f) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_604); + _172 = 1; + } else { + _166 = 0.5f; + _167 = 0.5f; + _168 = -0.03500000014901161f; + _169 = 1.0f; + _170 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600; + _171 = 1.0f; + _172 = 0; + } + } + _173 = _125 - _166; + _175 = (_126 - _167) * 0.5625f; + _177 = dot(float2(_173, _175), float2(_173, _175)) * _168; + _181 = (_125 + -0.5f) + (_177 * _173); + _183 = (_126 + -0.5f) + (_177 * _175); + _186 = 0.5f - _166; + _188 = 0.5f - _167; + _192 = (((_181 * _169) + _186) / _171) + _166; + _193 = (((_183 * _169) + _188) / _171) + _167; + _194 = t0.SampleLevel(s2, float2(_192, _193), 0.0f); + do { + _204 = _170; + if (_45) { + _204 = (saturate(dot(float3(_194.x, _194.y, _194.z), float3(0.30000001192092896f, 0.5899999737739563f, 0.10999999940395355f)) * 100.0f) * _170); + } + do { + _244 = _194.y; + _245 = _194.z; + if (_45 || (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600 == 0.0f))) { + _207 = _169 - (_204 * 0.6000000238418579f); + _214 = (((_207 * _181) + _186) / _171) + _166; + _215 = (((_207 * _183) + _188) / _171) + _167; + _217 = _169 - (_204 * 2.0f); + _224 = (((_217 * _181) + _186) / _171) + _166; + _225 = (((_217 * _183) + _188) / _171) + _167; + if (!(_45)) { + _244 = (((float4)(t0.SampleLevel(s2, float2(_214, _215), 0.0f))).y); + _245 = (((float4)(t0.SampleLevel(s2, float2(_224, _225), 0.0f))).z); + } else { + _232 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792 * 0.25f; + _233 = t0.SampleLevel(s2, float2(_214, _215), 0.0f); + _238 = t0.SampleLevel(s2, float2(_224, _225), 0.0f); + _244 = (lerp(_233.y, _194.y, _232)); + _245 = (lerp(_238.z, _194.z, _232)); + } + } + do { + _556 = _194.x; + _557 = _244; + _558 = _245; + if ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_512 & 7) == 7) { + do { + _297 = _194.x; + _298 = _244; + _299 = _245; + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584 > 0.0f) { + _248 = _194.x * _194.x; + _249 = _244 * _244; + _250 = _245 * _245; + _252 = saturate(dot(float3(_248, _249, _250), float3(0.30000001192092896f, 0.5899999737739563f, 0.10999999940395355f))); + _255 = _252 + 0.5f; + _268 = 0.5f - ((_252 + -0.5f) * 0.5f); + _297 = sqrt(((saturate(select((_248 > 0.5f), (1.0f - (_268 * (1.0f - ((_248 + -0.5f) * 2.0f)))), (_255 * _248))) - _248) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584) + _248); + _298 = sqrt(((saturate(select((_249 > 0.5f), (1.0f - (_268 * (1.0f - ((_249 + -0.5f) * 2.0f)))), (_255 * _249))) - _249) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584) + _249); + _299 = sqrt(((saturate(select((_250 > 0.5f), (1.0f - (_268 * (1.0f - ((_250 + -0.5f) * 2.0f)))), (_255 * _250))) - _250) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584) + _250); + } + do { + _323 = _297; + _324 = _298; + _325 = _299; + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812 > 0.0f) { + _323 = saturate((((((_297 * _297) * 6.0f) * _297) - _297) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812) + _297); + _324 = saturate((((((_298 * _298) * 6.0f) * _298) - _298) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812) + _298); + _325 = saturate((((((_299 * _299) * 6.0f) * _299) - _299) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812) + _299); + } + do { + _436 = _323; + _437 = _324; + _438 = _325; + if (!((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_516 & 2) == 0)) { + if (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_852 == 9)) { + _337 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_648 * (((float4)(t10.SampleLevel(s2, float2(_192, _193), 0.0f))).x)) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_652; + do { + _429 = _337; + _430 = _337; + _431 = _337; + if (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_680 == 0)) { + _347 = _20 - (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_544.x * 0.5f); + _348 = _21 - (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_544.y * 0.5f); + _362 = t11.SampleLevel(s2, float2(_192, _193), 0.0f); + _370 = max(((1.0f - saturate(((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_660 * sqrt((_347 * _347) + (_348 * _348))) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_664) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_668)) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_648), _362.w) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_656; + _371 = t12.SampleLevel(s2, float2(_192, _193), 0.0f); + do { + _421 = _370; + if (!(_371.x == 1.0f)) { + _379 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (_371.x - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x); + _380 = sqrt(_379); + _384 = -0.7071067690849304f; + _385 = -0.7071067690849304f; + _386 = 0.0f; + _387 = 0; + bool _loop_break_4 = false; + while (true) { + _399 = max(((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (((t12.SampleLevel(s2, float2(saturate((_385 * (0.004166666883975267f / _380)) + _192), saturate((_384 * (0.007407407276332378f / _380)) + _193)), 0.0f)).x) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x)) - _379), _386); + _400 = _387 + 1; + if (!(_400 == 8)) { + _404 = _global_0[min((uint)(_400), 7u)]; + _406 = _global_1[min((uint)(_400), 7u)]; + _384 = _406; + _385 = _404; + _386 = _399; + _387 = _400; + _loop_break_4 = true; + break; + } else { + _408 = min(_399, 0.10000000149011612f); + _421 = (((_408 * _408) * _370) * max((1.0f - saturate((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_672 * _379) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_676)), _362.w)); + } + break; + } + } + _429 = ((_421 * _362.x) + _337); + _430 = ((_421 * _362.y) + _337); + _431 = ((_421 * _362.z) + _337); + } while (false); + } + _436 = (_429 + _323); + _437 = (_430 + _324); + _438 = (_431 + _325); + } while (false); + } else { + _436 = _323; + _437 = _324; + _438 = _325; + } + } + do { + _494 = _436; + _495 = _437; + _496 = _438; + if (!((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_516 & 1) == 0)) { + _445 = t9.SampleLevel(s2, float2(_192, _193), 0.0f); + if (!(_445.x == 1.0f)) { + _449 = t8.SampleLevel(s2, float2(_192, _193), 0.0f); + _452 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (_445.x - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x); + _453 = sqrt(_452); + _457 = -0.7071067690849304f; + _458 = -0.7071067690849304f; + _459 = 0.0f; + _460 = 0; + bool _loop_break_5 = false; + while (true) { + _472 = max(((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (((t9.SampleLevel(s2, float2(saturate((_458 * (0.004166666883975267f / _453)) + _192), saturate((_457 * (0.007407407276332378f / _453)) + _193)), 0.0f)).x) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x)) - _452), _459); + _473 = _460 + 1; + if (!(_473 == 8)) { + _477 = _global_0[min((uint)(_473), 7u)]; + _479 = _global_1[min((uint)(_473), 7u)]; + _457 = _479; + _458 = _477; + _459 = _472; + _460 = _473; + _loop_break_5 = true; + break; + } else { + _484 = min(max(_472, 0.0f), 0.10000000149011612f); + _486 = (_484 * _484) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_640; + _494 = ((_486 * _449.x) + _436); + _495 = ((_486 * _449.y) + _437); + _496 = ((_486 * _449.z) + _438); + } + break; + } + } else { + _494 = _436; + _495 = _437; + _496 = _438; + } + } + do { + _541 = _494; + _542 = _495; + _543 = _496; + if (_57) { + _520 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.x * _192) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.z) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.z; + _521 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.y * _193) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.w) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.w; + _528 = saturate(saturate((1.0f - (sqrt(dot(float2(_520, _521), float2(_520, _521))) * 2.0f)) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.y)); + _533 = 1.0f - ((_528 * _528) * (3.0f - (_528 * 2.0f))); + _541 = (((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.x * (1.0f - _494)) * _533) + _494); + _542 = (((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.y * (1.0f - _495)) * _533) + _495); + _543 = (((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.z * (1.0f - _496)) * _533) + _496); + } + if (!((_172 != 0) || (!_45))) { + _551 = 1.0f - ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792 * 0.1499999761581421f) * saturate(_125)); + _556 = (_551 * _541); + _557 = (_551 * _542); + _558 = (_551 * _543); + } else { + _556 = _541; + _557 = _542; + _558 = _543; + } + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } + do { + _578 = _556; + _579 = _557; + _580 = _558; + if (_64) { + _564 = t7.SampleLevel(s2, float2((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x * _33), (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y * _25)), 0.0f); + _578 = (lerp(_556, _564.x, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612)); + _579 = (lerp(_557, _564.y, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612)); + _580 = (lerp(_558, _564.z, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612)); + } + do { + _614 = _578; + _615 = _579; + _616 = _580; + if (_66) { + _588 = t7.SampleLevel(s2, float2((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x * _33), (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y * _25)), 0.0f); + _593 = (1.0f - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_616) * 1.0999999046325684f; + _596 = select((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_620 == 0), _33, (1.0f - _33)); + _597 = _593 + -0.10000000149011612f; + if (!(_596 < _597)) { + if (!(_596 > _593)) { + _603 = (_596 - _597) * 10.0f; + _614 = (lerp(_578, _588.x, _603)); + _615 = (lerp(_579, _588.y, _603)); + _616 = (lerp(_580, _588.z, _603)); + } else { + _614 = _588.x; + _615 = _588.y; + _616 = _588.z; + } + } else { + _614 = _578; + _615 = _579; + _616 = _580; + } + } + do { + _653 = _614; + _654 = _615; + _655 = _616; + if ((_64 || _66) && (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_856 != 0)) { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684 > 0.0f) { + _639 = t17.SampleLevel(s2, float3(((saturate(_614) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.z), ((saturate(_615) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.z), ((saturate(_616) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.z)), 0.0f); + _653 = (lerp(_614, _639.x, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684)); + _654 = (lerp(_615, _639.y, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684)); + _655 = (lerp(_616, _639.z, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684)); + } else { + _653 = _614; + _654 = _615; + _655 = _616; + } + } + do { + _720 = _653; + _721 = _654; + _722 = _655; + if (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_624 == 0)) { + _665 = int(float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_632)) / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x); + _666 = int(float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_636)) / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y); + uint2 _667; + t7.GetDimensions(_667.x, _667.y); + if (!((int)(int)(SV_DispatchThreadID.x) < (int)_665)) { + _673 = float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_628)); + if (((int)(int)(SV_DispatchThreadID.y) < (int)(int(_673 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y) + _666)) && (((int)(int)(SV_DispatchThreadID.y) >= (int)_666) && ((int)(int)(SV_DispatchThreadID.x) < (int)(int(float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_624)) / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x) + _665)))) { + _692 = float((int)((int)(SV_DispatchThreadID.y - _666))) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y; + _694 = (float)((uint)_667.y); + _695 = (float((int)((int)(SV_DispatchThreadID.x - _665))) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x) / ((float)((uint)_667.x)); + _697 = t7.SampleLevel(s2, float2(_695, (_692 / _694)), 0.0f); + _703 = t7.SampleLevel(s2, float2(_695, ((_692 + _673) / _694)), 0.0f); + _709 = ((_703.x + _703.y) + _703.z) * 0.3333333432674408f; + _720 = ((_709 * (_697.x - _653)) + _653); + _721 = ((_709 * (_697.y - _654)) + _654); + _722 = ((_709 * (_697.z - _655)) + _655); + } else { + _720 = _653; + _721 = _654; + _722 = _655; + } + } else { + _720 = _653; + _721 = _654; + _722 = _655; + } + } + do { + _745 = ApplyVanillaFilmGrain(float3(_720, _721, _722), float2(_33, _25)); + _823 = _745.x; + _824 = _745.y; + _825 = _745.z; + _837 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_336.x * _823) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_352.x; + _838 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_336.y * _824) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_352.y; + _839 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_336.z * _825) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_352.z; + do { + _1057 = _837; + _1058 = _838; + _1059 = _839; + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832 < 1.0f) { + if (sqrt(((_837 * _837) + (_838 * _838)) + (_839 * _839)) > 0.0f) { + _852 = _192 + -0.5f; + _859 = saturate(((abs(_852) * 2.0f) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832) / (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_836 - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832)); + if (_859 > 0.0f) { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_848 == 1) { + _866 = _859 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_840; + _870 = uint(ceil(_866 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_844)); + if (!(_870 == 0)) { + _875 = int(_192 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x); + _876 = int(_193 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y); + _894 = ((float((int)(((_876 + _875) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_828) & 1)) * 0.10000000149011612f) + frac((((float((int)(_876)) * 2.0f) + float((int)(_875))) + ((float)((uint)(uint)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_824)))) * 0.20000000298023224f)) * 6.2831854820251465f; + _898 = 1.0f / ((float)((uint)_870)); + _901 = 0; + _902 = 0.0f; + _903 = 0.0f; + _904 = 0.0f; + _905 = (_898 * 0.5f); + _906 = cos(_894); + _907 = sin(_894); + bool _loop_break_6 = false; + while (true) { + _909 = sqrt(_905) * _866; + _916 = t0.SampleLevel(s2, float2((((_906 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x) * _909) + _192), (((_907 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y) * _909) + _193)), 0.0f); + _920 = _916.x + _902; + _921 = _916.y + _903; + _922 = _916.z + _904; + _930 = _901 + 1u; + do { + if (!(_930 == _870)) { + _901 = _930; + _902 = _920; + _903 = _921; + _904 = _922; + _905 = (_905 + _898); + _906 = ((_906 * -0.7373688220977783f) - (_907 * 0.6754903793334961f)); + _907 = ((_906 * 0.6754903793334961f) - (_907 * 0.7373688220977783f)); + _loop_break_6 = true; + break; + } + _936 = saturate(_866); + _1057 = ((_936 * ((_920 * _898) - _837)) + _837); + _1058 = ((_936 * ((_921 * _898) - _838)) + _838); + _1059 = ((_936 * ((_922 * _898) - _839)) + _839); + } while (false); + if (_loop_break_6) { + _loop_break_6 = false; + continue; + } + break; + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } else { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_848 == 2) { + _950 = _859 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_840; + _954 = uint(ceil(_950 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_844)); + if (!(_954 == 0)) { + _959 = int(_192 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x); + _960 = int(_193 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y); + _978 = ((float((int)(((_960 + _959) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_828) & 1)) * 0.10000000149011612f) + frac((((float((int)(_960)) * 2.0f) + float((int)(_959))) + ((float)((uint)(uint)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_824)))) * 0.20000000298023224f)) * 6.2831854820251465f; + _985 = 1.0f / ((float)((uint)_954)); + _988 = cos(_978); + _989 = sin(_978); + _990 = (_985 * 0.5f); + _991 = 0.0f; + _992 = 0.0f; + _993 = 0.0f; + _994 = 0; + bool _loop_break_7 = false; + while (true) { + _996 = sqrt(_990) * _950; + _1007 = -0.0f - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832; + _1016 = t0.SampleLevel(s2, float2(((min(max(((((_988 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x) * _996) + (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832 * _852)) * 2.0f), _1007), g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832) * 0.5f) + 0.5f), ((min(max(((((_989 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y) * _996) + (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832 * (_193 + -0.5f))) * 2.0f), _1007), g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832) * 0.5f) + 0.5f)), 0.0f); + _1020 = _1016.x + _991; + _1021 = _1016.y + _992; + _1022 = _1016.z + _993; + _1030 = _994 + 1u; + do { + if (!(_1030 == _954)) { + _988 = ((_988 * -0.7373688220977783f) - (_989 * 0.6754903793334961f)); + _989 = ((_988 * 0.6754903793334961f) - (_989 * 0.7373688220977783f)); + _990 = (_990 + _985); + _991 = _1020; + _992 = _1021; + _993 = _1022; + _994 = _1030; + _loop_break_7 = true; + break; + } + _1036 = saturate(_859); + _1057 = ((_1036 * ((_1020 * _985) - _837)) + _837); + _1058 = ((_1036 * ((_1021 * _985) - _838)) + _838); + _1059 = ((_1036 * ((_1022 * _985) - _839)) + _839); + } while (false); + if (_loop_break_7) { + _loop_break_7 = false; + continue; + } + break; + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } else { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_848 == 3) { + _1049 = saturate(_859); + _1057 = (_837 - (_1049 * _837)); + _1058 = (_838 - (_1049 * _838)); + _1059 = (_839 - (_1049 * _839)); + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } + u0[int2((int)(SV_DispatchThreadID.x), (int)(SV_DispatchThreadID.y))] = float4(max(_1057, 0.0f), max(_1058, 0.0f), max(_1059, 0.0f), 0.0f); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } + } while (false); + } + } else { + if (_90 < g_postPostProcessingShaderConst_000.PostProcessingShaderConst_820) { + _108 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_560.y - ((_90 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_820) * _89)) * 0.5f; + if ((_21 < _108) || (_21 > (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_560.y - _108))) { + _115 = true; + do { + if (_115) { + u0[int2((int)(SV_DispatchThreadID.x), (int)(SV_DispatchThreadID.y))] = float4(0.0f, 0.0f, 0.0f, 0.0f); + } else { + _118 = t2.SampleLevel(s2, float2(_33, _25), 0.0f); + _125 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x * 1.5f) * _118.x) + _33; + _126 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y * 1.5f) * _118.y) + _25; + do { + _166 = 0.5f; + _167 = 0.5f; + _168 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_592; + _169 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_596; + _170 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600; + _171 = 1.0f; + _172 = 0; + if (_45) { + _133 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.y - _126; + _135 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.x - _125) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_800; + _139 = sqrt((_135 * _135) + (_133 * _133)); + if (_139 < g_postPostProcessingShaderConst_000.PostProcessingShaderConst_796) { + _143 = _139 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_796; + _144 = saturate(_143); + _145 = _144 * _144; + _166 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.x; + _167 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.y; + _168 = (((((_145 * _145) * (_144 * 1.5f)) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_592) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_592); + _169 = (lerp(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_596, 0.9200000166893005f, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792)); + _170 = ((((_143 * 0.02250000089406967f) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600); + _171 = (((_143 * (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_608 + -1.0f)) + 1.0f) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_604); + _172 = 1; + } else { + _166 = 0.5f; + _167 = 0.5f; + _168 = -0.03500000014901161f; + _169 = 1.0f; + _170 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600; + _171 = 1.0f; + _172 = 0; + } + } + _173 = _125 - _166; + _175 = (_126 - _167) * 0.5625f; + _177 = dot(float2(_173, _175), float2(_173, _175)) * _168; + _181 = (_125 + -0.5f) + (_177 * _173); + _183 = (_126 + -0.5f) + (_177 * _175); + _186 = 0.5f - _166; + _188 = 0.5f - _167; + _192 = (((_181 * _169) + _186) / _171) + _166; + _193 = (((_183 * _169) + _188) / _171) + _167; + _194 = t0.SampleLevel(s2, float2(_192, _193), 0.0f); + do { + _204 = _170; + if (_45) { + _204 = (saturate(dot(float3(_194.x, _194.y, _194.z), float3(0.30000001192092896f, 0.5899999737739563f, 0.10999999940395355f)) * 100.0f) * _170); + } + do { + _244 = _194.y; + _245 = _194.z; + if (_45 || (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600 == 0.0f))) { + _207 = _169 - (_204 * 0.6000000238418579f); + _214 = (((_207 * _181) + _186) / _171) + _166; + _215 = (((_207 * _183) + _188) / _171) + _167; + _217 = _169 - (_204 * 2.0f); + _224 = (((_217 * _181) + _186) / _171) + _166; + _225 = (((_217 * _183) + _188) / _171) + _167; + if (!(_45)) { + _244 = (((float4)(t0.SampleLevel(s2, float2(_214, _215), 0.0f))).y); + _245 = (((float4)(t0.SampleLevel(s2, float2(_224, _225), 0.0f))).z); + } else { + _232 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792 * 0.25f; + _233 = t0.SampleLevel(s2, float2(_214, _215), 0.0f); + _238 = t0.SampleLevel(s2, float2(_224, _225), 0.0f); + _244 = (lerp(_233.y, _194.y, _232)); + _245 = (lerp(_238.z, _194.z, _232)); + } + } + do { + _556 = _194.x; + _557 = _244; + _558 = _245; + if ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_512 & 7) == 7) { + do { + _297 = _194.x; + _298 = _244; + _299 = _245; + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584 > 0.0f) { + _248 = _194.x * _194.x; + _249 = _244 * _244; + _250 = _245 * _245; + _252 = saturate(dot(float3(_248, _249, _250), float3(0.30000001192092896f, 0.5899999737739563f, 0.10999999940395355f))); + _255 = _252 + 0.5f; + _268 = 0.5f - ((_252 + -0.5f) * 0.5f); + _297 = sqrt(((saturate(select((_248 > 0.5f), (1.0f - (_268 * (1.0f - ((_248 + -0.5f) * 2.0f)))), (_255 * _248))) - _248) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584) + _248); + _298 = sqrt(((saturate(select((_249 > 0.5f), (1.0f - (_268 * (1.0f - ((_249 + -0.5f) * 2.0f)))), (_255 * _249))) - _249) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584) + _249); + _299 = sqrt(((saturate(select((_250 > 0.5f), (1.0f - (_268 * (1.0f - ((_250 + -0.5f) * 2.0f)))), (_255 * _250))) - _250) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584) + _250); + } + do { + _323 = _297; + _324 = _298; + _325 = _299; + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812 > 0.0f) { + _323 = saturate((((((_297 * _297) * 6.0f) * _297) - _297) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812) + _297); + _324 = saturate((((((_298 * _298) * 6.0f) * _298) - _298) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812) + _298); + _325 = saturate((((((_299 * _299) * 6.0f) * _299) - _299) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812) + _299); + } + do { + _436 = _323; + _437 = _324; + _438 = _325; + if (!((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_516 & 2) == 0)) { + if (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_852 == 9)) { + _337 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_648 * (((float4)(t10.SampleLevel(s2, float2(_192, _193), 0.0f))).x)) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_652; + do { + _429 = _337; + _430 = _337; + _431 = _337; + if (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_680 == 0)) { + _347 = _20 - (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_544.x * 0.5f); + _348 = _21 - (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_544.y * 0.5f); + _362 = t11.SampleLevel(s2, float2(_192, _193), 0.0f); + _370 = max(((1.0f - saturate(((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_660 * sqrt((_347 * _347) + (_348 * _348))) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_664) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_668)) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_648), _362.w) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_656; + _371 = t12.SampleLevel(s2, float2(_192, _193), 0.0f); + do { + _421 = _370; + if (!(_371.x == 1.0f)) { + _379 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (_371.x - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x); + _380 = sqrt(_379); + _384 = -0.7071067690849304f; + _385 = -0.7071067690849304f; + _386 = 0.0f; + _387 = 0; + bool _loop_break_8 = false; + while (true) { + _399 = max(((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (((t12.SampleLevel(s2, float2(saturate((_385 * (0.004166666883975267f / _380)) + _192), saturate((_384 * (0.007407407276332378f / _380)) + _193)), 0.0f)).x) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x)) - _379), _386); + _400 = _387 + 1; + if (!(_400 == 8)) { + _404 = _global_0[min((uint)(_400), 7u)]; + _406 = _global_1[min((uint)(_400), 7u)]; + _384 = _406; + _385 = _404; + _386 = _399; + _387 = _400; + _loop_break_8 = true; + break; + } else { + _408 = min(_399, 0.10000000149011612f); + _421 = (((_408 * _408) * _370) * max((1.0f - saturate((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_672 * _379) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_676)), _362.w)); + } + break; + } + } + _429 = ((_421 * _362.x) + _337); + _430 = ((_421 * _362.y) + _337); + _431 = ((_421 * _362.z) + _337); + } while (false); + } + _436 = (_429 + _323); + _437 = (_430 + _324); + _438 = (_431 + _325); + } while (false); + } else { + _436 = _323; + _437 = _324; + _438 = _325; + } + } + do { + _494 = _436; + _495 = _437; + _496 = _438; + if (!((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_516 & 1) == 0)) { + _445 = t9.SampleLevel(s2, float2(_192, _193), 0.0f); + if (!(_445.x == 1.0f)) { + _449 = t8.SampleLevel(s2, float2(_192, _193), 0.0f); + _452 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (_445.x - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x); + _453 = sqrt(_452); + _457 = -0.7071067690849304f; + _458 = -0.7071067690849304f; + _459 = 0.0f; + _460 = 0; + bool _loop_break_9 = false; + while (true) { + _472 = max(((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (((t9.SampleLevel(s2, float2(saturate((_458 * (0.004166666883975267f / _453)) + _192), saturate((_457 * (0.007407407276332378f / _453)) + _193)), 0.0f)).x) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x)) - _452), _459); + _473 = _460 + 1; + if (!(_473 == 8)) { + _477 = _global_0[min((uint)(_473), 7u)]; + _479 = _global_1[min((uint)(_473), 7u)]; + _457 = _479; + _458 = _477; + _459 = _472; + _460 = _473; + _loop_break_9 = true; + break; + } else { + _484 = min(max(_472, 0.0f), 0.10000000149011612f); + _486 = (_484 * _484) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_640; + _494 = ((_486 * _449.x) + _436); + _495 = ((_486 * _449.y) + _437); + _496 = ((_486 * _449.z) + _438); + } + break; + } + } else { + _494 = _436; + _495 = _437; + _496 = _438; + } + } + do { + _541 = _494; + _542 = _495; + _543 = _496; + if (_57) { + _520 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.x * _192) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.z) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.z; + _521 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.y * _193) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.w) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.w; + _528 = saturate(saturate((1.0f - (sqrt(dot(float2(_520, _521), float2(_520, _521))) * 2.0f)) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.y)); + _533 = 1.0f - ((_528 * _528) * (3.0f - (_528 * 2.0f))); + _541 = (((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.x * (1.0f - _494)) * _533) + _494); + _542 = (((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.y * (1.0f - _495)) * _533) + _495); + _543 = (((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.z * (1.0f - _496)) * _533) + _496); + } + if (!((_172 != 0) || (!_45))) { + _551 = 1.0f - ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792 * 0.1499999761581421f) * saturate(_125)); + _556 = (_551 * _541); + _557 = (_551 * _542); + _558 = (_551 * _543); + } else { + _556 = _541; + _557 = _542; + _558 = _543; + } + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } + do { + _578 = _556; + _579 = _557; + _580 = _558; + if (_64) { + _564 = t7.SampleLevel(s2, float2((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x * _33), (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y * _25)), 0.0f); + _578 = (lerp(_556, _564.x, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612)); + _579 = (lerp(_557, _564.y, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612)); + _580 = (lerp(_558, _564.z, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612)); + } + do { + _614 = _578; + _615 = _579; + _616 = _580; + if (_66) { + _588 = t7.SampleLevel(s2, float2((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x * _33), (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y * _25)), 0.0f); + _593 = (1.0f - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_616) * 1.0999999046325684f; + _596 = select((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_620 == 0), _33, (1.0f - _33)); + _597 = _593 + -0.10000000149011612f; + if (!(_596 < _597)) { + if (!(_596 > _593)) { + _603 = (_596 - _597) * 10.0f; + _614 = (lerp(_578, _588.x, _603)); + _615 = (lerp(_579, _588.y, _603)); + _616 = (lerp(_580, _588.z, _603)); + } else { + _614 = _588.x; + _615 = _588.y; + _616 = _588.z; + } + } else { + _614 = _578; + _615 = _579; + _616 = _580; + } + } + do { + _653 = _614; + _654 = _615; + _655 = _616; + if ((_64 || _66) && (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_856 != 0)) { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684 > 0.0f) { + _639 = t17.SampleLevel(s2, float3(((saturate(_614) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.z), ((saturate(_615) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.z), ((saturate(_616) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.z)), 0.0f); + _653 = (lerp(_614, _639.x, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684)); + _654 = (lerp(_615, _639.y, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684)); + _655 = (lerp(_616, _639.z, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684)); + } else { + _653 = _614; + _654 = _615; + _655 = _616; + } + } + do { + _720 = _653; + _721 = _654; + _722 = _655; + if (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_624 == 0)) { + _665 = int(float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_632)) / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x); + _666 = int(float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_636)) / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y); + uint2 _667; + t7.GetDimensions(_667.x, _667.y); + if (!((int)(int)(SV_DispatchThreadID.x) < (int)_665)) { + _673 = float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_628)); + if (((int)(int)(SV_DispatchThreadID.y) < (int)(int(_673 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y) + _666)) && (((int)(int)(SV_DispatchThreadID.y) >= (int)_666) && ((int)(int)(SV_DispatchThreadID.x) < (int)(int(float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_624)) / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x) + _665)))) { + _692 = float((int)((int)(SV_DispatchThreadID.y - _666))) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y; + _694 = (float)((uint)_667.y); + _695 = (float((int)((int)(SV_DispatchThreadID.x - _665))) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x) / ((float)((uint)_667.x)); + _697 = t7.SampleLevel(s2, float2(_695, (_692 / _694)), 0.0f); + _703 = t7.SampleLevel(s2, float2(_695, ((_692 + _673) / _694)), 0.0f); + _709 = ((_703.x + _703.y) + _703.z) * 0.3333333432674408f; + _720 = ((_709 * (_697.x - _653)) + _653); + _721 = ((_709 * (_697.y - _654)) + _654); + _722 = ((_709 * (_697.z - _655)) + _655); + } else { + _720 = _653; + _721 = _654; + _722 = _655; + } + } else { + _720 = _653; + _721 = _654; + _722 = _655; + } + } + do { + _745 = ApplyVanillaFilmGrain(float3(_720, _721, _722), float2(_33, _25)); + _823 = _745.x; + _824 = _745.y; + _825 = _745.z; + _837 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_336.x * _823) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_352.x; + _838 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_336.y * _824) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_352.y; + _839 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_336.z * _825) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_352.z; + do { + _1057 = _837; + _1058 = _838; + _1059 = _839; + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832 < 1.0f) { + if (sqrt(((_837 * _837) + (_838 * _838)) + (_839 * _839)) > 0.0f) { + _852 = _192 + -0.5f; + _859 = saturate(((abs(_852) * 2.0f) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832) / (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_836 - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832)); + if (_859 > 0.0f) { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_848 == 1) { + _866 = _859 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_840; + _870 = uint(ceil(_866 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_844)); + if (!(_870 == 0)) { + _875 = int(_192 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x); + _876 = int(_193 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y); + _894 = ((float((int)(((_876 + _875) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_828) & 1)) * 0.10000000149011612f) + frac((((float((int)(_876)) * 2.0f) + float((int)(_875))) + ((float)((uint)(uint)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_824)))) * 0.20000000298023224f)) * 6.2831854820251465f; + _898 = 1.0f / ((float)((uint)_870)); + _901 = 0; + _902 = 0.0f; + _903 = 0.0f; + _904 = 0.0f; + _905 = (_898 * 0.5f); + _906 = cos(_894); + _907 = sin(_894); + bool _loop_break_10 = false; + while (true) { + _909 = sqrt(_905) * _866; + _916 = t0.SampleLevel(s2, float2((((_906 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x) * _909) + _192), (((_907 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y) * _909) + _193)), 0.0f); + _920 = _916.x + _902; + _921 = _916.y + _903; + _922 = _916.z + _904; + _930 = _901 + 1u; + do { + if (!(_930 == _870)) { + _901 = _930; + _902 = _920; + _903 = _921; + _904 = _922; + _905 = (_905 + _898); + _906 = ((_906 * -0.7373688220977783f) - (_907 * 0.6754903793334961f)); + _907 = ((_906 * 0.6754903793334961f) - (_907 * 0.7373688220977783f)); + _loop_break_10 = true; + break; + } + _936 = saturate(_866); + _1057 = ((_936 * ((_920 * _898) - _837)) + _837); + _1058 = ((_936 * ((_921 * _898) - _838)) + _838); + _1059 = ((_936 * ((_922 * _898) - _839)) + _839); + } while (false); + if (_loop_break_10) { + _loop_break_10 = false; + continue; + } + break; + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } else { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_848 == 2) { + _950 = _859 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_840; + _954 = uint(ceil(_950 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_844)); + if (!(_954 == 0)) { + _959 = int(_192 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x); + _960 = int(_193 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y); + _978 = ((float((int)(((_960 + _959) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_828) & 1)) * 0.10000000149011612f) + frac((((float((int)(_960)) * 2.0f) + float((int)(_959))) + ((float)((uint)(uint)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_824)))) * 0.20000000298023224f)) * 6.2831854820251465f; + _985 = 1.0f / ((float)((uint)_954)); + _988 = cos(_978); + _989 = sin(_978); + _990 = (_985 * 0.5f); + _991 = 0.0f; + _992 = 0.0f; + _993 = 0.0f; + _994 = 0; + bool _loop_break_11 = false; + while (true) { + _996 = sqrt(_990) * _950; + _1007 = -0.0f - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832; + _1016 = t0.SampleLevel(s2, float2(((min(max(((((_988 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x) * _996) + (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832 * _852)) * 2.0f), _1007), g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832) * 0.5f) + 0.5f), ((min(max(((((_989 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y) * _996) + (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832 * (_193 + -0.5f))) * 2.0f), _1007), g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832) * 0.5f) + 0.5f)), 0.0f); + _1020 = _1016.x + _991; + _1021 = _1016.y + _992; + _1022 = _1016.z + _993; + _1030 = _994 + 1u; + do { + if (!(_1030 == _954)) { + _988 = ((_988 * -0.7373688220977783f) - (_989 * 0.6754903793334961f)); + _989 = ((_988 * 0.6754903793334961f) - (_989 * 0.7373688220977783f)); + _990 = (_990 + _985); + _991 = _1020; + _992 = _1021; + _993 = _1022; + _994 = _1030; + _loop_break_11 = true; + break; + } + _1036 = saturate(_859); + _1057 = ((_1036 * ((_1020 * _985) - _837)) + _837); + _1058 = ((_1036 * ((_1021 * _985) - _838)) + _838); + _1059 = ((_1036 * ((_1022 * _985) - _839)) + _839); + } while (false); + if (_loop_break_11) { + _loop_break_11 = false; + continue; + } + break; + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } else { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_848 == 3) { + _1049 = saturate(_859); + _1057 = (_837 - (_1049 * _837)); + _1058 = (_838 - (_1049 * _838)); + _1059 = (_839 - (_1049 * _839)); + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } + u0[int2((int)(SV_DispatchThreadID.x), (int)(SV_DispatchThreadID.y))] = float4(max(_1057, 0.0f), max(_1058, 0.0f), max(_1059, 0.0f), 0.0f); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } + } while (false); + } + } + } + _115 = false; + if (_115) { + u0[int2((int)(SV_DispatchThreadID.x), (int)(SV_DispatchThreadID.y))] = float4(0.0f, 0.0f, 0.0f, 0.0f); + } else { + _118 = t2.SampleLevel(s2, float2(_33, _25), 0.0f); + _125 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x * 1.5f) * _118.x) + _33; + _126 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y * 1.5f) * _118.y) + _25; + do { + _166 = 0.5f; + _167 = 0.5f; + _168 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_592; + _169 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_596; + _170 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600; + _171 = 1.0f; + _172 = 0; + if (_45) { + _133 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.y - _126; + _135 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.x - _125) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_800; + _139 = sqrt((_135 * _135) + (_133 * _133)); + if (_139 < g_postPostProcessingShaderConst_000.PostProcessingShaderConst_796) { + _143 = _139 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_796; + _144 = saturate(_143); + _145 = _144 * _144; + _166 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.x; + _167 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_568.y; + _168 = (((((_145 * _145) * (_144 * 1.5f)) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_592) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_592); + _169 = (lerp(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_596, 0.9200000166893005f, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792)); + _170 = ((((_143 * 0.02250000089406967f) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600); + _171 = (((_143 * (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_608 + -1.0f)) + 1.0f) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_604); + _172 = 1; + } else { + _166 = 0.5f; + _167 = 0.5f; + _168 = -0.03500000014901161f; + _169 = 1.0f; + _170 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600; + _171 = 1.0f; + _172 = 0; + } + } + _173 = _125 - _166; + _175 = (_126 - _167) * 0.5625f; + _177 = dot(float2(_173, _175), float2(_173, _175)) * _168; + _181 = (_125 + -0.5f) + (_177 * _173); + _183 = (_126 + -0.5f) + (_177 * _175); + _186 = 0.5f - _166; + _188 = 0.5f - _167; + _192 = (((_181 * _169) + _186) / _171) + _166; + _193 = (((_183 * _169) + _188) / _171) + _167; + _194 = t0.SampleLevel(s2, float2(_192, _193), 0.0f); + do { + _204 = _170; + if (_45) { + _204 = (saturate(dot(float3(_194.x, _194.y, _194.z), float3(0.30000001192092896f, 0.5899999737739563f, 0.10999999940395355f)) * 100.0f) * _170); + } + do { + _244 = _194.y; + _245 = _194.z; + if (_45 || (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_600 == 0.0f))) { + _207 = _169 - (_204 * 0.6000000238418579f); + _214 = (((_207 * _181) + _186) / _171) + _166; + _215 = (((_207 * _183) + _188) / _171) + _167; + _217 = _169 - (_204 * 2.0f); + _224 = (((_217 * _181) + _186) / _171) + _166; + _225 = (((_217 * _183) + _188) / _171) + _167; + if (!(_45)) { + _244 = (((float4)(t0.SampleLevel(s2, float2(_214, _215), 0.0f))).y); + _245 = (((float4)(t0.SampleLevel(s2, float2(_224, _225), 0.0f))).z); + } else { + _232 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792 * 0.25f; + _233 = t0.SampleLevel(s2, float2(_214, _215), 0.0f); + _238 = t0.SampleLevel(s2, float2(_224, _225), 0.0f); + _244 = (lerp(_233.y, _194.y, _232)); + _245 = (lerp(_238.z, _194.z, _232)); + } + } + do { + _556 = _194.x; + _557 = _244; + _558 = _245; + if ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_512 & 7) == 7) { + do { + _297 = _194.x; + _298 = _244; + _299 = _245; + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584 > 0.0f) { + _248 = _194.x * _194.x; + _249 = _244 * _244; + _250 = _245 * _245; + _252 = saturate(dot(float3(_248, _249, _250), float3(0.30000001192092896f, 0.5899999737739563f, 0.10999999940395355f))); + _255 = _252 + 0.5f; + _268 = 0.5f - ((_252 + -0.5f) * 0.5f); + _297 = sqrt(((saturate(select((_248 > 0.5f), (1.0f - (_268 * (1.0f - ((_248 + -0.5f) * 2.0f)))), (_255 * _248))) - _248) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584) + _248); + _298 = sqrt(((saturate(select((_249 > 0.5f), (1.0f - (_268 * (1.0f - ((_249 + -0.5f) * 2.0f)))), (_255 * _249))) - _249) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584) + _249); + _299 = sqrt(((saturate(select((_250 > 0.5f), (1.0f - (_268 * (1.0f - ((_250 + -0.5f) * 2.0f)))), (_255 * _250))) - _250) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_584) + _250); + } + do { + _323 = _297; + _324 = _298; + _325 = _299; + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812 > 0.0f) { + _323 = saturate((((((_297 * _297) * 6.0f) * _297) - _297) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812) + _297); + _324 = saturate((((((_298 * _298) * 6.0f) * _298) - _298) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812) + _298); + _325 = saturate((((((_299 * _299) * 6.0f) * _299) - _299) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_812) + _299); + } + do { + _436 = _323; + _437 = _324; + _438 = _325; + if (!((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_516 & 2) == 0)) { + if (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_852 == 9)) { + _337 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_648 * (((float4)(t10.SampleLevel(s2, float2(_192, _193), 0.0f))).x)) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_652; + do { + _429 = _337; + _430 = _337; + _431 = _337; + if (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_680 == 0)) { + _347 = _20 - (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_544.x * 0.5f); + _348 = _21 - (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_544.y * 0.5f); + _362 = t11.SampleLevel(s2, float2(_192, _193), 0.0f); + _370 = max(((1.0f - saturate(((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_660 * sqrt((_347 * _347) + (_348 * _348))) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_664) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_668)) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_648), _362.w) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_656; + _371 = t12.SampleLevel(s2, float2(_192, _193), 0.0f); + do { + _421 = _370; + if (!(_371.x == 1.0f)) { + _379 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (_371.x - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x); + _380 = sqrt(_379); + _384 = -0.7071067690849304f; + _385 = -0.7071067690849304f; + _386 = 0.0f; + _387 = 0; + bool _loop_break_12 = false; + while (true) { + _399 = max(((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (((t12.SampleLevel(s2, float2(saturate((_385 * (0.004166666883975267f / _380)) + _192), saturate((_384 * (0.007407407276332378f / _380)) + _193)), 0.0f)).x) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x)) - _379), _386); + _400 = _387 + 1; + if (!(_400 == 8)) { + _404 = _global_0[min((uint)(_400), 7u)]; + _406 = _global_1[min((uint)(_400), 7u)]; + _384 = _406; + _385 = _404; + _386 = _399; + _387 = _400; + _loop_break_12 = true; + break; + } else { + _408 = min(_399, 0.10000000149011612f); + _421 = (((_408 * _408) * _370) * max((1.0f - saturate((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_672 * _379) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_676)), _362.w)); + } + break; + } + } + _429 = ((_421 * _362.x) + _337); + _430 = ((_421 * _362.y) + _337); + _431 = ((_421 * _362.z) + _337); + } while (false); + } + _436 = (_429 + _323); + _437 = (_430 + _324); + _438 = (_431 + _325); + } while (false); + } else { + _436 = _323; + _437 = _324; + _438 = _325; + } + } + do { + _494 = _436; + _495 = _437; + _496 = _438; + if (!((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_516 & 1) == 0)) { + _445 = t9.SampleLevel(s2, float2(_192, _193), 0.0f); + if (!(_445.x == 1.0f)) { + _449 = t8.SampleLevel(s2, float2(_192, _193), 0.0f); + _452 = g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (_445.x - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x); + _453 = sqrt(_452); + _457 = -0.7071067690849304f; + _458 = -0.7071067690849304f; + _459 = 0.0f; + _460 = 0; + bool _loop_break_13 = false; + while (true) { + _472 = max(((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (((t9.SampleLevel(s2, float2(saturate((_458 * (0.004166666883975267f / _453)) + _192), saturate((_457 * (0.007407407276332378f / _453)) + _193)), 0.0f)).x) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_520.x)) - _452), _459); + _473 = _460 + 1; + if (!(_473 == 8)) { + _477 = _global_0[min((uint)(_473), 7u)]; + _479 = _global_1[min((uint)(_473), 7u)]; + _457 = _479; + _458 = _477; + _459 = _472; + _460 = _473; + _loop_break_13 = true; + break; + } else { + _484 = min(max(_472, 0.0f), 0.10000000149011612f); + _486 = (_484 * _484) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_640; + _494 = ((_486 * _449.x) + _436); + _495 = ((_486 * _449.y) + _437); + _496 = ((_486 * _449.z) + _438); + } + break; + } + } else { + _494 = _436; + _495 = _437; + _496 = _438; + } + } + do { + _541 = _494; + _542 = _495; + _543 = _496; + if (_57) { + _520 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.x * _192) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.z) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.z; + _521 = ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.y * _193) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_384.w) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.w; + _528 = saturate(saturate((1.0f - (sqrt(dot(float2(_520, _521), float2(_520, _521))) * 2.0f)) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_368.y)); + _533 = 1.0f - ((_528 * _528) * (3.0f - (_528 * 2.0f))); + _541 = (((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.x * (1.0f - _494)) * _533) + _494); + _542 = (((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.y * (1.0f - _495)) * _533) + _495); + _543 = (((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_416.z * (1.0f - _496)) * _533) + _496); + } + if (!((_172 != 0) || (!_45))) { + _551 = 1.0f - ((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_792 * 0.1499999761581421f) * saturate(_125)); + _556 = (_551 * _541); + _557 = (_551 * _542); + _558 = (_551 * _543); + } else { + _556 = _541; + _557 = _542; + _558 = _543; + } + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } + do { + _578 = _556; + _579 = _557; + _580 = _558; + if (_64) { + _564 = t7.SampleLevel(s2, float2((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x * _33), (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y * _25)), 0.0f); + _578 = (lerp(_556, _564.x, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612)); + _579 = (lerp(_557, _564.y, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612)); + _580 = (lerp(_558, _564.z, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_612)); + } + do { + _614 = _578; + _615 = _579; + _616 = _580; + if (_66) { + _588 = t7.SampleLevel(s2, float2((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x * _33), (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y * _25)), 0.0f); + _593 = (1.0f - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_616) * 1.0999999046325684f; + _596 = select((g_postPostProcessingShaderConst_000.PostProcessingShaderConst_620 == 0), _33, (1.0f - _33)); + _597 = _593 + -0.10000000149011612f; + if (!(_596 < _597)) { + if (!(_596 > _593)) { + _603 = (_596 - _597) * 10.0f; + _614 = (lerp(_578, _588.x, _603)); + _615 = (lerp(_579, _588.y, _603)); + _616 = (lerp(_580, _588.z, _603)); + } else { + _614 = _588.x; + _615 = _588.y; + _616 = _588.z; + } + } else { + _614 = _578; + _615 = _579; + _616 = _580; + } + } + do { + _653 = _614; + _654 = _615; + _655 = _616; + if ((_64 || _66) && (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_856 != 0)) { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684 > 0.0f) { + _639 = t17.SampleLevel(s2, float3(((saturate(_614) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.z), ((saturate(_615) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.z), ((saturate(_616) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_432.z)), 0.0f); + _653 = (lerp(_614, _639.x, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684)); + _654 = (lerp(_615, _639.y, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684)); + _655 = (lerp(_616, _639.z, g_postPostProcessingShaderConst_000.PostProcessingShaderConst_684)); + } else { + _653 = _614; + _654 = _615; + _655 = _616; + } + } + do { + _720 = _653; + _721 = _654; + _722 = _655; + if (!(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_624 == 0)) { + _665 = int(float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_632)) / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x); + _666 = int(float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_636)) / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y); + uint2 _667; + t7.GetDimensions(_667.x, _667.y); + if (!((int)(int)(SV_DispatchThreadID.x) < (int)_665)) { + _673 = float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_628)); + if (((int)(int)(SV_DispatchThreadID.y) < (int)(int(_673 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y) + _666)) && (((int)(int)(SV_DispatchThreadID.y) >= (int)_666) && ((int)(int)(SV_DispatchThreadID.x) < (int)(int(float((int)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_624)) / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x) + _665)))) { + _692 = float((int)((int)(SV_DispatchThreadID.y - _666))) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.y; + _694 = (float)((uint)_667.y); + _695 = (float((int)((int)(SV_DispatchThreadID.x - _665))) * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_576.x) / ((float)((uint)_667.x)); + _697 = t7.SampleLevel(s2, float2(_695, (_692 / _694)), 0.0f); + _703 = t7.SampleLevel(s2, float2(_695, ((_692 + _673) / _694)), 0.0f); + _709 = ((_703.x + _703.y) + _703.z) * 0.3333333432674408f; + _720 = ((_709 * (_697.x - _653)) + _653); + _721 = ((_709 * (_697.y - _654)) + _654); + _722 = ((_709 * (_697.z - _655)) + _655); + } else { + _720 = _653; + _721 = _654; + _722 = _655; + } + } else { + _720 = _653; + _721 = _654; + _722 = _655; + } + } + do { + _745 = ApplyVanillaFilmGrain(float3(_720, _721, _722), float2(_33, _25)); + _823 = _745.x; + _824 = _745.y; + _825 = _745.z; + _837 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_336.x * _823) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_352.x; + _838 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_336.y * _824) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_352.y; + _839 = (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_336.z * _825) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_352.z; + do { + _1057 = _837; + _1058 = _838; + _1059 = _839; + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832 < 1.0f) { + if (sqrt(((_837 * _837) + (_838 * _838)) + (_839 * _839)) > 0.0f) { + _852 = _192 + -0.5f; + _859 = saturate(((abs(_852) * 2.0f) - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832) / (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_836 - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832)); + if (_859 > 0.0f) { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_848 == 1) { + _866 = _859 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_840; + _870 = uint(ceil(_866 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_844)); + if (!(_870 == 0)) { + _875 = int(_192 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x); + _876 = int(_193 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y); + _894 = ((float((int)(((_876 + _875) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_828) & 1)) * 0.10000000149011612f) + frac((((float((int)(_876)) * 2.0f) + float((int)(_875))) + ((float)((uint)(uint)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_824)))) * 0.20000000298023224f)) * 6.2831854820251465f; + _898 = 1.0f / ((float)((uint)_870)); + _901 = 0; + _902 = 0.0f; + _903 = 0.0f; + _904 = 0.0f; + _905 = (_898 * 0.5f); + _906 = cos(_894); + _907 = sin(_894); + bool _loop_break_14 = false; + while (true) { + _909 = sqrt(_905) * _866; + _916 = t0.SampleLevel(s2, float2((((_906 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x) * _909) + _192), (((_907 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y) * _909) + _193)), 0.0f); + _920 = _916.x + _902; + _921 = _916.y + _903; + _922 = _916.z + _904; + _930 = _901 + 1u; + do { + if (!(_930 == _870)) { + _901 = _930; + _902 = _920; + _903 = _921; + _904 = _922; + _905 = (_905 + _898); + _906 = ((_906 * -0.7373688220977783f) - (_907 * 0.6754903793334961f)); + _907 = ((_906 * 0.6754903793334961f) - (_907 * 0.7373688220977783f)); + _loop_break_14 = true; + break; + } + _936 = saturate(_866); + _1057 = ((_936 * ((_920 * _898) - _837)) + _837); + _1058 = ((_936 * ((_921 * _898) - _838)) + _838); + _1059 = ((_936 * ((_922 * _898) - _839)) + _839); + } while (false); + if (_loop_break_14) { + _loop_break_14 = false; + continue; + } + break; + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } else { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_848 == 2) { + _950 = _859 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_840; + _954 = uint(ceil(_950 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_844)); + if (!(_954 == 0)) { + _959 = int(_192 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x); + _960 = int(_193 / g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y); + _978 = ((float((int)(((_960 + _959) + g_postPostProcessingShaderConst_000.PostProcessingShaderConst_828) & 1)) * 0.10000000149011612f) + frac((((float((int)(_960)) * 2.0f) + float((int)(_959))) + ((float)((uint)(uint)(g_postPostProcessingShaderConst_000.PostProcessingShaderConst_824)))) * 0.20000000298023224f)) * 6.2831854820251465f; + _985 = 1.0f / ((float)((uint)_954)); + _988 = cos(_978); + _989 = sin(_978); + _990 = (_985 * 0.5f); + _991 = 0.0f; + _992 = 0.0f; + _993 = 0.0f; + _994 = 0; + bool _loop_break_15 = false; + while (true) { + _996 = sqrt(_990) * _950; + _1007 = -0.0f - g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832; + _1016 = t0.SampleLevel(s2, float2(((min(max(((((_988 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.x) * _996) + (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832 * _852)) * 2.0f), _1007), g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832) * 0.5f) + 0.5f), ((min(max(((((_989 * g_postPostProcessingShaderConst_000.PostProcessingShaderConst_536.y) * _996) + (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832 * (_193 + -0.5f))) * 2.0f), _1007), g_postPostProcessingShaderConst_000.PostProcessingShaderConst_832) * 0.5f) + 0.5f)), 0.0f); + _1020 = _1016.x + _991; + _1021 = _1016.y + _992; + _1022 = _1016.z + _993; + _1030 = _994 + 1u; + do { + if (!(_1030 == _954)) { + _988 = ((_988 * -0.7373688220977783f) - (_989 * 0.6754903793334961f)); + _989 = ((_988 * 0.6754903793334961f) - (_989 * 0.7373688220977783f)); + _990 = (_990 + _985); + _991 = _1020; + _992 = _1021; + _993 = _1022; + _994 = _1030; + _loop_break_15 = true; + break; + } + _1036 = saturate(_859); + _1057 = ((_1036 * ((_1020 * _985) - _837)) + _837); + _1058 = ((_1036 * ((_1021 * _985) - _838)) + _838); + _1059 = ((_1036 * ((_1022 * _985) - _839)) + _839); + } while (false); + if (_loop_break_15) { + _loop_break_15 = false; + continue; + } + break; + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } else { + if (g_postPostProcessingShaderConst_000.PostProcessingShaderConst_848 == 3) { + _1049 = saturate(_859); + _1057 = (_837 - (_1049 * _837)); + _1058 = (_838 - (_1049 * _838)); + _1059 = (_839 - (_1049 * _839)); + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } else { + _1057 = _837; + _1058 = _838; + _1059 = _839; + } + } + float3 final_output = tlou2::post_post_processing::ApplyFinalOutput(float3(_1057, _1058, _1059), float2(_33, _25)); + u0[int2((int)(SV_DispatchThreadID.x), (int)(SV_DispatchThreadID.y))] = float4(max(final_output, 0.0f), 0.0f); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } while (false); + } +} diff --git a/src/games/tlou2/CS_PrePostProcessing_0x535F90FB.cs_6_0.hlsl b/src/games/tlou2/CS_PrePostProcessing_0x535F90FB.cs_6_0.hlsl new file mode 100644 index 000000000..c175370a6 --- /dev/null +++ b/src/games/tlou2/CS_PrePostProcessing_0x535F90FB.cs_6_0.hlsl @@ -0,0 +1,927 @@ +#include "./common.hlsli" + +struct PostProcessingShaderConst { + float4 PostProcessingShaderConst_000[4]; + float4 PostProcessingShaderConst_064[4]; + float4 PostProcessingShaderConst_128; + float4 PostProcessingShaderConst_144; + float4 PostProcessingShaderConst_160; + float4 PostProcessingShaderConst_176; + float4 PostProcessingShaderConst_192; + float4 PostProcessingShaderConst_208; + float4 PostProcessingShaderConst_224; + float4 PostProcessingShaderConst_240; + float4 PostProcessingShaderConst_256; + float4 PostProcessingShaderConst_272; + float4 PostProcessingShaderConst_288; + float4 PostProcessingShaderConst_304; + float4 PostProcessingShaderConst_320; + float4 PostProcessingShaderConst_336; + float4 PostProcessingShaderConst_352; + float4 PostProcessingShaderConst_368; + float4 PostProcessingShaderConst_384; + float4 PostProcessingShaderConst_400; + float4 PostProcessingShaderConst_416; + float4 PostProcessingShaderConst_432; + float4 PostProcessingShaderConst_448; + float4 PostProcessingShaderConst_464; + float4 PostProcessingShaderConst_480; + float4 PostProcessingShaderConst_496; + int PostProcessingShaderConst_512; + int PostProcessingShaderConst_516; + float2 PostProcessingShaderConst_520; + float2 PostProcessingShaderConst_528; + float2 PostProcessingShaderConst_536; + float2 PostProcessingShaderConst_544; + float2 PostProcessingShaderConst_552; + float2 PostProcessingShaderConst_560; + float2 PostProcessingShaderConst_568; + float2 PostProcessingShaderConst_576; + float PostProcessingShaderConst_584; + float PostProcessingShaderConst_588; + float PostProcessingShaderConst_592; + float PostProcessingShaderConst_596; + float PostProcessingShaderConst_600; + float PostProcessingShaderConst_604; + float PostProcessingShaderConst_608; + float PostProcessingShaderConst_612; + float PostProcessingShaderConst_616; + int PostProcessingShaderConst_620; + int PostProcessingShaderConst_624; + int PostProcessingShaderConst_628; + int PostProcessingShaderConst_632; + int PostProcessingShaderConst_636; + float PostProcessingShaderConst_640; + float PostProcessingShaderConst_644; + float PostProcessingShaderConst_648; + float PostProcessingShaderConst_652; + float PostProcessingShaderConst_656; + float PostProcessingShaderConst_660; + float PostProcessingShaderConst_664; + float PostProcessingShaderConst_668; + float PostProcessingShaderConst_672; + float PostProcessingShaderConst_676; + int PostProcessingShaderConst_680; + float PostProcessingShaderConst_684; + float PostProcessingShaderConst_688; + int PostProcessingShaderConst_692; + float PostProcessingShaderConst_696; + float PostProcessingShaderConst_700; + int PostProcessingShaderConst_704; + float PostProcessingShaderConst_708; + float PostProcessingShaderConst_712; + int PostProcessingShaderConst_716; + int PostProcessingShaderConst_720; + float PostProcessingShaderConst_724; + float PostProcessingShaderConst_728; + float PostProcessingShaderConst_732; + float PostProcessingShaderConst_736; + float PostProcessingShaderConst_740; + float PostProcessingShaderConst_744; + float PostProcessingShaderConst_748; + float PostProcessingShaderConst_752; + float PostProcessingShaderConst_756; + float PostProcessingShaderConst_760; + int PostProcessingShaderConst_764; + int PostProcessingShaderConst_768; + int PostProcessingShaderConst_772; + float PostProcessingShaderConst_776; + float PostProcessingShaderConst_780; + float PostProcessingShaderConst_784; + float PostProcessingShaderConst_788; + float PostProcessingShaderConst_792; + float PostProcessingShaderConst_796; + float PostProcessingShaderConst_800; + float PostProcessingShaderConst_804; + int PostProcessingShaderConst_808; + float PostProcessingShaderConst_812; + float PostProcessingShaderConst_816; + float PostProcessingShaderConst_820; + int PostProcessingShaderConst_824; + int PostProcessingShaderConst_828; + float PostProcessingShaderConst_832; + float PostProcessingShaderConst_836; + float PostProcessingShaderConst_840; + float PostProcessingShaderConst_844; + int PostProcessingShaderConst_848; + int PostProcessingShaderConst_852; + int PostProcessingShaderConst_856; + float PostProcessingShaderConst_860; + float PostProcessingShaderConst_864; + float PostProcessingShaderConst_868; + float PostProcessingShaderConst_872; + float PostProcessingShaderConst_876; +}; + +Texture2D t0 : register(t0); + +Texture2D t1 : register(t1); + +Texture2D t2 : register(t2); + +Texture2D t3 : register(t3); + +Texture2D t4 : register(t4); + +Texture2D t5 : register(t5); + +Texture2D t6 : register(t6); + +Texture3D t7 : register(t7); + +Texture3D t8 : register(t8); + +Texture2D t9 : register(t9); + +ByteAddressBuffer t10 : register(t10); + +RWTexture2D u0 : register(u0); + +cbuffer cb0 : register(b0) { + PostProcessingShaderConst g_prePostProcessingShaderConst_000 : packoffset(c000.x); +}; + +SamplerState s0 : register(s0); + +SamplerState s1 : register(s1); + +// Reverse-engineering notes (names are inferred from data flow, not recovered symbols): +// t0 Main scene/post-process color. +// t1/t2 Low-resolution effect color plus scalar blend/transmittance data. These are +// composited directly or depth-aware upsampled before being merged with t0. +// t3 Full-resolution depth used by bilateral upsampling and world-position reconstruction. +// t4 Depth used to derive the circle-of-confusion/defocus amount. +// t5 Low-resolution depth paired with t1/t2 for bilateral upsampling. +// t6 Additive/screen-like bloom or light-effect contribution. +// t7 Optional first 3D color LUT, sampled through a custom extended-range shaper. +// t8 Second 3D color LUT/color grade, blended by Const_684. +// t9 Auxiliary per-pixel gate used to restrict the sharpening path. +// t10 Byte-address buffer containing the current exposure/pre-exposure scalar. +// u0 Pre-post-processing output consumed by later post-processing passes. +// +// Approximate pipeline: +// 1. Sample scene color, depth, exposure, and derive a depth-of-field amount. +// 2. Run a combined 3x3 sharpening and depth-of-field neighborhood filter. +// 3. Composite a low-resolution effect using optional depth-aware bilateral upsampling. +// 4. Add bloom/light effects, vignette, luminance-dependent desaturation, and directional masks. +// 5. Apply exposure and the optional rational/filmic tonemap. +// 6. Encode to sRGB where required and sample up to two 3D color LUTs. +// 7. Select or blend the configured output-mode/reference-color paths. +// 8. Add an optional depth-reconstructed localized glow, shape dark values, and clamp output. +// +// This is a decompilation, so effect labels such as "bloom" and "localized glow" describe +// the observed math; the engine's original feature names remain unproven. +float3 ApplyLUTShaper(float3 encoded_color, float input_scale) { + float3 scaled_color = encoded_color * input_scale; + return saturate((max(pow(scaled_color, 0.75f) - 1.f, 0.f) + saturate(scaled_color)) / input_scale * 0.5f); +} + +[numthreads(8, 8, 1)] +void main( + uint3 SV_DispatchThreadID: SV_DispatchThreadID, + uint3 SV_GroupID: SV_GroupID, + uint3 SV_GroupThreadID: SV_GroupThreadID, + uint SV_GroupIndex: SV_GroupIndex) { + bool _29; + bool _32; + bool _35; + float _36; + float _37; + float _43; + float _44; + float4 _45; + float _49; + int _52; + float _53; + // uint2 _68; + float _70; + float _73; + float _76; + float _78; + float _79; + float _83; + float _84; + float _92; + float _103; + float _111; + int _121; + float _131; + float _136; + float _137; + float _138; + float _139; + float _140; + float _141; + int _142; + float _144; + float _145; + float _146; + float _147; + float _148; + float _149; + int _150; + bool _161; + float _198; + float _199; + float _200; + float _201; + float _202; + float _203; + float _259; + float _260; + float _261; + float _283; + float _284; + float _285; + float _459; + float _460; + float _461; + bool _475; + float _500; + float _501; + float _502; + float _548; + float _549; + float _550; + float _567; + float _578; + float _579; + float _580; + float _616; + float _617; + float _641; + float _642; + float _643; + float _667; + float _668; + float _669; + float _729; + float _730; + float _731; + float _732; + float _733; + float _734; + float _824; + float _825; + float _826; + float _852; + float _853; + float _854; + float _897; + float _898; + float _899; + float _924; + float _936; + float _937; + float _938; + float _1053; + float _1054; + float _1055; + float _1075; + float _1076; + float _1077; + float _134; + int _156; + float _162; + float _163; + float _165; + float _166; + float _167; + float _170; + float4 _181; + int _204; + int _207; + float _210; + bool _247; + float _248; + float _249; + float _250; + float _274; + float3 _278; + float _289; + float _300; + float _301; + float _304; + float _305; + int _306; + int _307; + float _308; + float _309; + float _310; + float _316; + uint _319; + uint _322; + float _331; + float _332; + float _333; + float _334; + float _347; + float _348; + float _353; + float _354; + float _355; + float _356; + float3 _363; + float _367; + float _380; + float _381; + float3 _382; + float3 _388; + float3 _394; + float3 _400; + float _434; + float _438; + float3 _446; + float _450; + float _479; + float4 _489; + float _522; + float _523; + float _530; + float _534; + float _554; + float _561; + float _590; + float _591; + float _600; + float _605; + float _610; + float _612; + float _636; + float _662; + int _673; + float _674; + bool _681; + float _689; + float _690; + float _691; + bool _753; + float _776; + float _777; + float _778; + float4 _819; + float4 _838; + bool _856; + float _880; + float _881; + float _882; + float _883; + float _914; + float _925; + float _945; + float _981; + float _984; + float _1000; + float _1008; + float _1010; + float _1012; + float _1018; + float _1019; + float _1021; + float _1048; + float _1059; + float _1067; + // Feature gates for two directional masks and the neighborhood defocus filter. + _29 = (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_724 > 0.0f); + _32 = (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_744 > 0.0f); + _35 = (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_776 > 0.0f); + // Step 1: build pixel-center UV and fetch the main color and depth inputs. + _36 = float((int)((int)(SV_DispatchThreadID.x))); + _37 = float((int)((int)(SV_DispatchThreadID.y))); + _43 = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_536.x * (_36 + 0.5f); + _44 = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_536.y * (_37 + 0.5f); + _45 = t0.SampleLevel(s1, float2(_43, _44), 0.0f); + _49 = t4.SampleLevel(s0, float2(_43, _44), 0.0f); + // t10[0] is interpreted as a float exposure/pre-exposure value. + _52 = t10.Load4(0).x; + _53 = asfloat(_52); + uint2 _68; + t0.GetDimensions(_68.x, _68.y); + _70 = float((int)((int)(_68.x))); + // Step 2: reconstruct depth and derive a resolution-normalized circle-of-confusion. + // Positive values become _111, the blend weight for the 3x3 defocus neighborhood. + _73 = 2560.0f / _70; + _76 = 1.0f / ((_49.x * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_528.x) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_528.y); + _78 = 1.0f / (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_528.x + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_528.y); + _79 = _76 - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_780; + _83 = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_784 * (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_780 + -0.07000000029802322f); + _84 = ((_79 / _76) * 0.004900000058114529f) / _83; + _92 = ((_73 * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_788) * _84) * ((_84 * _83) / (((_78 - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_780) / _78) * 0.004900000058114529f)); + _103 = 1.0f - _49.x; + _111 = (max(float((int)(((int)(uint)((int)(_79 > 0.0f))) - ((int)(uint)((int)(_79 < 0.0f))))), 0.0f) * saturate(_92 * _92)) * max(float((int)(((int)(uint)((int)(_103 > 0.0f))) - ((int)(uint)((int)(_103 < 0.0f))))), 0.0f); + // Decide whether sharpening is active. One mode gates it with t9.x; the fallback + // enables it whenever the configured sharpening coefficient is nontrivial. + if (!(g_prePostProcessingShaderConst_000.PostProcessingShaderConst_720 == 0)) { + _121 = ((int)(uint)((int)((((float3)(t9.SampleLevel(s0, float2(_43, _44), 0.0f))).x) < 0.800000011920929f))); + } else { + _121 = ((int)(uint)((int)(abs(g_prePostProcessingShaderConst_000.PostProcessingShaderConst_708) > 0.0010000000474974513f))); + } + if (_53 > 0.0f) { + _131 = max(((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_712 - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_320.z) / _53), 0.0f); + } else { + _131 = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_712; + } + // Step 3: traverse a 3x3 neighborhood. Cardinal samples form a Laplacian-like + // sharpen term; all eight neighbors form the depth-of-field box-filter term. + _134 = _111 * 0.125f; + _136 = (_45.x * 4.0f); + _137 = (_45.y * 4.0f); + _138 = (_45.z * 4.0f); + _139 = 0.0f; + _140 = 0.0f; + _141 = 0.0f; + _142 = -1; + bool _loop_break_0 = false; + while (true) { + _144 = _136; + _145 = _137; + _146 = _138; + _147 = _139; + _148 = _140; + _149 = _141; + _150 = -1; + bool _loop_break_1 = false; + while (true) { + if (!((_150 | _142) == 0)) { + do { + _161 = false; + if (!(_121 == 0)) { + _156 = _150 + _142; + _161 = (max((int)(_156), (int)((0 - _156))) == 1); + } + _162 = float((int)(_142)); + _163 = float((int)(_150)); + _165 = rsqrt(dot(float2(_162, _163), float2(_162, _163))); + _166 = _165 * _162; + _167 = _165 * _163; + if (_35 || _161) { + _170 = _111 * 0.75f; + _181 = t0.SampleLevel(s1, float2((((((_166 * _170) + _166) * (1.0f / _70)) / _73) + _43), (((((_167 * _170) + _167) * (1.0f / float((int)((int)(_68.y))))) / _73) + _44)), 0.0f); + _198 = select(_161, (_144 - _181.x), _144); + _199 = select(_161, (_145 - _181.y), _145); + _200 = select(_161, (_146 - _181.z), _146); + _201 = ((_181.x * _134) + _147); + _202 = ((_181.y * _134) + _148); + _203 = ((_181.z * _134) + _149); + } else { + _198 = _144; + _199 = _145; + _200 = _146; + _201 = _147; + _202 = _148; + _203 = _149; + } + } while (false); + if (_loop_break_1 && !_loop_break_0) { + _loop_break_1 = false; + continue; + } + } else { + _198 = _144; + _199 = _145; + _200 = _146; + _201 = _147; + _202 = _148; + _203 = _149; + } + _204 = _150 + 1; + if (!(_204 == 2)) { + _144 = _198; + _145 = _199; + _146 = _200; + _147 = _201; + _148 = _202; + _149 = _203; + _150 = _204; + continue; + } + _207 = _142 + 1; + if (!(_207 == 2)) { + _136 = _198; + _137 = _199; + _138 = _200; + _139 = _201; + _140 = _202; + _141 = _203; + _142 = _207; + _loop_break_0 = true; + break; + } + _210 = 1.0f - _111; + _247 = (_121 != 0); + // Apply the bounded signed sharpening correction to the center sample. + _248 = select(_247, max((((float((int)(((int)(uint)((int)(_198 > 0.0f))) - ((int)(uint)((int)(_198 < 0.0f))))) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_708) * min(abs(_198), _131)) + _45.x), 0.0f), _45.x); + _249 = select(_247, max((((float((int)(((int)(uint)((int)(_199 > 0.0f))) - ((int)(uint)((int)(_199 < 0.0f))))) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_708) * min(abs(_199), _131)) + _45.y), 0.0f), _45.y); + _250 = select(_247, max((((float((int)(((int)(uint)((int)(_200 > 0.0f))) - ((int)(uint)((int)(_200 < 0.0f))))) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_708) * min(abs(_200), _131)) + _45.z), 0.0f), _45.z); + if (_35) { + // Blend the sharpened center toward the accumulated 3x3 neighborhood by CoC. + _259 = ((_248 * _210) + _201); + _260 = ((_249 * _210) + _202); + _261 = ((_250 * _210) + _203); + } else { + _259 = _248; + _260 = _249; + _261 = _250; + } + // Step 4: composite the low-resolution effect carried by t1/t2. + // Depending on flags this is a direct alpha blend, an additive/transmittance + // composite, or a depth-aware bilateral upsample using full-res t3 and low-res t5. + if (!((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_512 & 8) == 0)) { + if (!((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_516 & 16384) == 0)) { + _274 = t2.SampleLevel(s1, float2(_43, _44), 0.0f); + do { + _283 = 0.0f; + _284 = 0.0f; + _285 = 0.0f; + if (_274.x > 0.0f) { + _278 = t1.SampleLevel(s1, float2(_43, _44), 0.0f); + _283 = _278.x; + _284 = _278.y; + _285 = _278.z; + } + _289 = 1.0f - _274.x; + _459 = ((_283 * _274.x) + (_289 * _259)); + _460 = ((_284 * _274.x) + (_289 * _260)); + _461 = ((_285 * _274.x) + (_289 * _261)); + } while (false); + if (_loop_break_1 && !_loop_break_0) { + _loop_break_1 = false; + continue; + } + } else { + if (!((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_516 & 4) == 0)) { + _300 = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_544.x * 0.5f; + _301 = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_544.y * 0.5f; + _304 = (_300 * _43) + -0.5f; + _305 = (_301 * _44) + -0.5f; + _306 = int(_304); + _307 = int(_305); + _308 = frac(_304); + _309 = frac(_305); + _310 = t3.SampleLevel(s0, float2(_43, _44), 0.0f); + _316 = min(select((_310.x == 1.0f), 65535.0f, (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_520.y / (_310.x - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_520.x))), 50.0f); + // Compare reconstructed full-resolution depth against the four neighboring + // low-resolution depths and use exponential depth similarity as sample weights. + _319 = _306 + 1u; + _322 = _307 + 1u; + _331 = abs(((t5.Load(int3(_306, _307, 0))).x) - _316); + _332 = abs(((t5.Load(int3(_319, _307, 0))).x) - _316); + _333 = abs(((t5.Load(int3(_306, _322, 0))).x) - _316); + _334 = abs(((t5.Load(int3(_319, _322, 0))).x) - _316); + _347 = 1.0f - _308; + _348 = 1.0f - _309; + _353 = (_348 * _347) * max(exp2(_331 * -10.0f), 9.999999747378752e-06f); + _354 = (_348 * _308) * max(exp2(_332 * -10.0f), 9.999999747378752e-06f); + _355 = (_347 * _309) * max(exp2(_333 * -10.0f), 9.999999747378752e-06f); + _356 = (_309 * _308) * max(exp2(_334 * -10.0f), 9.999999747378752e-06f); + if ((max(max(_331, max(_332, _333)), _334) / _316) < 0.00800000037997961f) { + _363 = t1.SampleLevel(s1, float2(_43, _44), 0.0f); + _367 = t2.SampleLevel(s1, float2(_43, _44), 0.0f); + _459 = ((_367.x * _259) + _363.x); + _460 = ((_367.x * _260) + _363.y); + _461 = ((_367.x * _261) + _363.z); + } else { + _380 = (float((int)(_306)) + 0.5f) / _300; + _381 = (float((int)(_307)) + 0.5f) / _301; + _382 = t1.SampleLevel(s0, float2(_380, _381), 0.0f); + _388 = t1.SampleLevel(s0, float2(_380, _381), 0.0f, int2(1, 0)); + _394 = t1.SampleLevel(s0, float2(_380, _381), 0.0f, int2(0, 1)); + _400 = t1.SampleLevel(s0, float2(_380, _381), 0.0f, int2(1, 1)); + _434 = dot(float4(_353, _354, _355, _356), float4(1.0f, 1.0f, 1.0f, 1.0f)); + _438 = ((((((t2.SampleLevel(s0, float2(_380, _381), 0.0f, int2(1, 0))).x) * _354) + (((t2.SampleLevel(s0, float2(_380, _381), 0.0f)).x) * _353)) + (((t2.SampleLevel(s0, float2(_380, _381), 0.0f, int2(0, 1))).x) * _355)) + (((t2.SampleLevel(s0, float2(_380, _381), 0.0f, int2(1, 1))).x) * _356)) / _434; + _459 = ((_438 * _259) + (((((_388.x * _354) + (_382.x * _353)) + (_394.x * _355)) + (_400.x * _356)) / _434)); + _460 = ((_438 * _260) + (((((_388.y * _354) + (_382.y * _353)) + (_394.y * _355)) + (_400.y * _356)) / _434)); + _461 = ((_438 * _261) + (((((_388.z * _354) + (_382.z * _353)) + (_394.z * _355)) + (_400.z * _356)) / _434)); + } + } else { + _446 = t1.SampleLevel(s1, float2(_43, _44), 0.0f); + _450 = t2.SampleLevel(s1, float2(_43, _44), 0.0f); + _459 = ((_450.x * _259) + _446.x); + _460 = ((_450.x * _260) + _446.y); + _461 = ((_450.x * _261) + _446.z); + } + } + } else { + _459 = _259; + _460 = _260; + _461 = _261; + } + // Step 5: optional screen-like bloom/light-effect composite from t6. The effect + // is strongest where the existing channel is below the configured normalization level. + if (!((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_512 & 1) == 0)) { + if (!(g_prePostProcessingShaderConst_000.PostProcessingShaderConst_288.x > 0.0f)) { + _475 = (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_272.x > 0.0f); + } else { + _475 = true; + } + } else { + _475 = false; + } + if (_475) { + _479 = max(g_prePostProcessingShaderConst_000.PostProcessingShaderConst_256.x, 1.0f); + _489 = t6.SampleLevel(s1, float2(_43, _44), 0.0f); + _500 = ((_489.x * (1.0f - saturate(_459 / _479))) + _459); + _501 = ((_489.y * (1.0f - saturate(_460 / _479))) + _460); + _502 = ((_489.z * (1.0f - saturate(_461 / _479))) + _461); + } else { + _500 = _459; + _501 = _460; + _502 = _461; + } + // Step 6: presentation grading before exposure/tonemapping. + if ((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_512 & 7) == 7) { + do { + _548 = _500; + _549 = _501; + _550 = _502; + // Elliptical vignette/color multiplier using Const_384 for UV transform, + // Const_368 for shape, and Const_400.rgb for edge color multipliers. + if (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_368.x > 0.5f) { + _522 = ((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_384.x * _43) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_384.z) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_368.z; + _523 = ((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_384.y * _44) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_384.w) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_368.w; + _530 = saturate(saturate((1.0f - (sqrt(dot(float2(_522, _523), float2(_522, _523))) * 2.0f)) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_368.y)); + _534 = (_530 * _530) * (3.0f - (_530 * 2.0f)); + _548 = (((_534 * (1.0f - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_400.x)) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_400.x) * _500); + _549 = (((_534 * (1.0f - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_400.y)) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_400.y) * _501); + _550 = (((_534 * (1.0f - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_400.z)) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_400.z) * _502); + } + _554 = dot(float3(_548, _549, _550), float3(0.30000001192092896f, 0.5899999737739563f, 0.10999999940395355f)); + do { + // Luminance-dependent desaturation: compute a gray reference and move each + // channel toward it by a strength that changes between shadows and highlights. + _567 = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_320.w; + if (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_320.y > 0.0f) { + _561 = saturate(1.0f - (_554 * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_320.y)); + _567 = (((_561 * _561) * (max(g_prePostProcessingShaderConst_000.PostProcessingShaderConst_320.x, g_prePostProcessingShaderConst_000.PostProcessingShaderConst_320.w) - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_320.w)) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_320.w); + } + _578 = ((_567 * (_554 - _548)) + _548); + _579 = ((_567 * (_554 - _549)) + _549); + _580 = ((_567 * (_554 - _550)) + _550); + } while (false); + if (_loop_break_1 && !_loop_break_0) break; + } while (false); + if (_loop_break_1 && !_loop_break_0) { + _loop_break_1 = false; + continue; + } + } else { + _578 = _500; + _579 = _501; + _580 = _502; + } + // Step 7: reconstruct and normalize a view/world direction from the supplied matrix. + // Its X/Y components drive two independent directional screen-space attenuation masks. + if (_29 || _32) { + _590 = (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_160.x * _36) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_160.z; + _591 = (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_160.y * _37) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_160.w; + _600 = dot(float4((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_000[0].x), (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_000[1].x), (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_000[2].x), (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_000[3].x)), float4(_590, _591, 1.0f, 0.0f)); + _605 = dot(float4((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_000[0].y), (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_000[1].y), (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_000[2].y), (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_000[3].y)), float4(_590, _591, 1.0f, 0.0f)); + _610 = dot(float4((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_000[0].z), (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_000[1].z), (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_000[2].z), (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_000[3].z)), float4(_590, _591, 1.0f, 0.0f)); + _612 = rsqrt(dot(float3(_600, _605, _610), float3(_600, _605, _610))); + _616 = (_612 * _600); + _617 = (_612 * _605); + } + if (_29) { + _636 = ((exp2(log2(saturate(1.0f - (((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_736 * _617) - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_732) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_740))) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_728) + -1.0f) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_724) + 1.0f; + _641 = (_636 * _578); + _642 = (_636 * _579); + _643 = (_636 * _580); + } else { + _641 = _578; + _642 = _579; + _643 = _580; + } + if (_32) { + _662 = ((exp2(log2(saturate(1.0f - (((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_756 * _616) - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_752) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_760))) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_748) + -1.0f) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_744) + 1.0f; + _667 = (_662 * _641); + _668 = (_662 * _642); + _669 = (_662 * _643); + } else { + _667 = _641; + _668 = _642; + _669 = _643; + } + // Step 8: apply the exposure/pre-exposure scalar from t10 and a black offset. + _673 = t10.Load4(0).x; + _674 = asfloat(_673); + _681 = (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_432.x > 0.5f); + if (RENODX_TONE_MAP_TYPE == 0.f) { + // Preserve the complete original SDR, HDR, and comparison-mode behavior. + if (!((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_512 & 4) == 0)) { + _689 = max(((_674 * _667) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_320.z), 0.0f); + _690 = max(((_674 * _668) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_320.z), 0.0f); + _691 = max(((_674 * _669) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_320.z), 0.0f); + // Optional rational/filmic tonemap. Keep _732.._734 as the exposed linear + // pre-tonemap reference for later output-mode and reconstruction branches. + if ((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_692 != 2) && (!_681)) { + if (!(g_prePostProcessingShaderConst_000.PostProcessingShaderConst_208.x == 0.0f)) { + _729 = ((((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_192.y * _689) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_192.z) / (((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_176.x + _689) * _689) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_176.y)) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_192.x); + _730 = ((((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_192.y * _690) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_192.z) / (((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_176.x + _690) * _690) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_176.y)) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_192.x); + _731 = ((((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_192.y * _691) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_192.z) / (((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_176.x + _691) * _691) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_176.y)) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_192.x); + _732 = _689; + _733 = _690; + _734 = _691; + } else { + _729 = _689; + _730 = _690; + _731 = _691; + _732 = _689; + _733 = _690; + _734 = _691; + } + } else { + _729 = _689; + _730 = _690; + _731 = _691; + _732 = _689; + _733 = _690; + _734 = _691; + } + } else { + _729 = _667; + _730 = _668; + _731 = _669; + _732 = _667; + _733 = _668; + _734 = _669; + } + _753 = (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_692 == 2); + if (!(_753)) { + // Step 9: encode the tonemapped color with the exact piecewise sRGB OETF. + _776 = renodx::color::srgb::Encode(_729); + _777 = renodx::color::srgb::Encode(_730); + _778 = renodx::color::srgb::Encode(_731); + if (_681) { + // Optional LUT A (t7). An extended-range shaper folds values above one + // into a bounded coordinate before applying the LUT scale and half-texel bias. + _819 = t7.SampleLevel(s1, ApplyLUTShaper(float3(_776, _777, _778), g_prePostProcessingShaderConst_000.PostProcessingShaderConst_688) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_432.y + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_432.z, + 0.0f); + _824 = _819.x; + _825 = _819.y; + _826 = _819.z; + } else { + _824 = _776; + _825 = _777; + _826 = _778; + } + } else { + _824 = _729; + _825 = _730; + _826 = _731; + } + // Optional LUT B (t8), blended with its input by Const_684. + if (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_684 > 0.0f) { + _838 = t8.SampleLevel(s1, float3(((saturate(_824) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_432.z), ((saturate(_825) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_432.z), ((saturate(_826) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_432.y) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_432.z)), 0.0f); + _852 = (lerp(_824, _838.x, g_prePostProcessingShaderConst_000.PostProcessingShaderConst_684)); + _853 = (lerp(_825, _838.y, g_prePostProcessingShaderConst_000.PostProcessingShaderConst_684)); + _854 = (lerp(_826, _838.z, g_prePostProcessingShaderConst_000.PostProcessingShaderConst_684)); + } else { + _852 = _824; + _853 = _825; + _854 = _826; + } + // Step 10: build an alternate sRGB reference from the exposed pre-tonemap color. + // Const_496 controls reference desaturation and per-channel scaling. Modes 2-4 + // select or spatially blend this reference against the LUT-processed result. + _856 = ((uint)((int)((uint)(g_prePostProcessingShaderConst_000.PostProcessingShaderConst_692) + (uint)(-3))) < (uint)2); + if (_753 || _856) { + _880 = renodx::color::srgb::Encode(_729); + _881 = renodx::color::srgb::Encode(_730); + _882 = renodx::color::srgb::Encode(_731); + _883 = dot(float3(_880, _881, _882), float3(0.21250000596046448f, 0.715399980545044f, 0.07209999859333038f)); + _897 = ((lerp(_880, _883, g_prePostProcessingShaderConst_000.PostProcessingShaderConst_496.w))*g_prePostProcessingShaderConst_000.PostProcessingShaderConst_496.x); + _898 = ((lerp(_881, _883, g_prePostProcessingShaderConst_000.PostProcessingShaderConst_496.w))*g_prePostProcessingShaderConst_000.PostProcessingShaderConst_496.y); + _899 = ((lerp(_882, _883, g_prePostProcessingShaderConst_000.PostProcessingShaderConst_496.w))*g_prePostProcessingShaderConst_000.PostProcessingShaderConst_496.z); + } else { + _897 = _732; + _898 = _733; + _899 = _734; + } + if (!(_753 || (!_856))) { + _914 = ((1.0f - (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_644 * 2.5f)) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_700) * exp2(log2(max(_852, max(_853, _854))) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_696); + do { + _924 = _914; + if (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_692 == 4) { + _924 = (saturate((((_37 * 0.25f) + _36) - float((int)(g_prePostProcessingShaderConst_000.PostProcessingShaderConst_704))) * 0.03999999910593033f) * _914); + } + _925 = saturate(_924); + _936 = ((_925 * (_897 - _852)) + _852); + _937 = ((_925 * (_898 - _853)) + _853); + _938 = ((_925 * (_899 - _854)) + _854); + } while (false); + if (_loop_break_1 && !_loop_break_0) { + _loop_break_1 = false; + continue; + } + } else { + _936 = select(_753, _897, _852); + _937 = select(_753, _898, _853); + _938 = select(_753, _899, _854); + } + } else { + const float3 processed_scene = float3(_667, _668, _669); + float3 exposed_scene = processed_scene; + float3 graded = processed_scene; + + float anchor = 0.18f; + float tm_peak = 1.f; + if ((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_512 & 4) != 0) { + exposed_scene = max((_674 * processed_scene) + g_prePostProcessingShaderConst_000.PostProcessingShaderConst_320.z, 0.f); + graded = exposed_scene; + + if (!_681 && g_prePostProcessingShaderConst_000.PostProcessingShaderConst_208.x != 0.f) { + const float A = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_192.y; + const float B = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_192.z; + const float C = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_176.x; + const float D = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_176.y; + const float E = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_192.x; + tm_peak = E; + + if (RENODX_TONE_MAP_TYPE == 1.f) { + graded = tlou2::tonemap::ApplyExtended(exposed_scene, A, B, C, D, E, anchor); + } else { + graded = tlou2::tonemap::Apply(exposed_scene, A, B, C, D, E); + } + } + } + + const float shaper_input_scale = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_688; + float scale = 1.f; + if (RENODX_TONE_MAP_TYPE == 1.f) { + scale = ApplyAnchoredCInfinityShoulderLuminanceScale(graded, tm_peak, anchor); + } + graded *= scale; + + const float3 encoded = renodx::color::srgb::Encode(graded); + float3 primary_lut_output = encoded; + + if (_681) { + primary_lut_output = renodx::lut::SampleTetrahedral(t7, ApplyLUTShaper(encoded, shaper_input_scale)); + } + + const float secondary_lut_strength = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_684; + float3 final_sdr = primary_lut_output; + if (secondary_lut_strength > 0.f) { + const float3 secondary_lut_output = renodx::lut::SampleTetrahedral(t8, primary_lut_output); + final_sdr = lerp(primary_lut_output, secondary_lut_output, secondary_lut_strength); + } + + final_sdr = renodx::color::srgb::DecodeSafe(final_sdr); + final_sdr /= scale; + final_sdr = renodx::color::srgb::EncodeSafe(final_sdr); + + _936 = final_sdr.x; + _937 = final_sdr.y; + _938 = final_sdr.z; + } + // Step 11: optional localized world-space glow/highlight. Reconstruct a position + // from t3 depth and the Const_064 matrix, measure distance to Const_144/128, + // then add the resulting scalar equally to RGB. Exact engine effect name is unknown. + if (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_144.w > 0.0f) { + _945 = t3.SampleLevel(s0, float2(_43, _44), 0.0f); + _981 = (((((float)((uint)SV_DispatchThreadID.x)) + 0.5f) * 2.0f) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_536.x) + -1.0f; + _984 = ((1.0f - (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_536.y * (((float)((uint)SV_DispatchThreadID.y)) + 0.5f))) * 2.0f) + -1.0f; + _1000 = mad((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[2].w), _945.x, mad((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[1].w), _984, (_981 * (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[0].w)))) + (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[3].w); + _1008 = (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_144.x - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_128.x) - ((mad((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[2].x), _945.x, mad((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[1].x), _984, (_981 * (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[0].x)))) + (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[3].x)) / _1000); + _1010 = (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_144.y - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_128.y) - ((mad((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[2].y), _945.x, mad((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[1].y), _984, (_981 * (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[0].y)))) + (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[3].y)) / _1000); + _1012 = (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_144.z - g_prePostProcessingShaderConst_000.PostProcessingShaderConst_128.z) - ((mad((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[2].z), _945.x, mad((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[1].z), _984, (_981 * (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[0].z)))) + (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_064[3].z)) / _1000); + _1018 = sqrt(((_1010 * _1010) + (_1008 * _1008)) + (_1012 * _1012)); + _1019 = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_144.w - _1018; + _1021 = saturate(_1019 * 2.0f); + _1048 = (saturate(exp2(log2(1.0f - (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_144.w / g_prePostProcessingShaderConst_000.PostProcessingShaderConst_804)) * 6.0f) * 2.0f) * saturate(max(0.0f, ((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_144.w * 2.0f) + -2.0f)) / g_prePostProcessingShaderConst_000.PostProcessingShaderConst_804)) * max((saturate(saturate(_1019 * 10.0f) - _1021) * 10.0f), (saturate(_1021 - saturate(((g_prePostProcessingShaderConst_000.PostProcessingShaderConst_144.w + -20.0f) - _1018) * 20.0f)) * 1.5f)); + _1053 = (_1048 + _936); + _1054 = (_1048 + _937); + _1055 = (_1048 + _938); + } else { + _1053 = _936; + _1054 = _937; + _1055 = _938; + } + // Step 12: optional luminance-derived dark-value suppression/contrast shaping. + if (g_prePostProcessingShaderConst_000.PostProcessingShaderConst_644 > 0.0f) { + _1059 = saturate(dot(float3(_1053, _1054, _1055), float3(0.21250000596046448f, 0.715399980545044f, 0.07209999859333038f))); + _1067 = 1.0f - (saturate(exp2(log2(_1059) * (1.0f / g_prePostProcessingShaderConst_000.PostProcessingShaderConst_644)) * g_prePostProcessingShaderConst_000.PostProcessingShaderConst_644) / _1059); + _1075 = max(0.0f, (_1067 * _1053)); + _1076 = max(0.0f, (_1067 * _1054)); + _1077 = max(0.0f, (_1067 * _1055)); + } else { + _1075 = _1053; + _1076 = _1054; + _1077 = _1055; + } + // Step 13: preserve the shader's extended positive range up to 100 and write alpha zero. + if (RENODX_TONE_MAP_TYPE == 0.f) { + _1075 = min(_1075, 100.f); + _1076 = min(_1076, 100.f); + _1077 = min(_1077, 100.f); + } + u0[int2((int)(SV_DispatchThreadID.x), (int)(SV_DispatchThreadID.y))] = float4(_1075, _1076, _1077, 0.0f); + break; + } + if (_loop_break_0) { + _loop_break_0 = false; + continue; + } + break; + } +} diff --git a/src/games/tlou2/PS_OutputToDisplayBufferHdr_0xAAEB4494.ps_6_0.hlsl b/src/games/tlou2/PS_OutputToDisplayBufferHdr_0xAAEB4494.ps_6_0.hlsl new file mode 100644 index 000000000..6993b8154 --- /dev/null +++ b/src/games/tlou2/PS_OutputToDisplayBufferHdr_0xAAEB4494.ps_6_0.hlsl @@ -0,0 +1,48 @@ +#include "./common.hlsli" + +struct ApplyHdrCodingConstants { + float pq_scaling; + float source_gamma; + float2 source_offset; +}; + +Texture2D back_buffer : register(t0); + +cbuffer cb0 : register(b0) { + ApplyHdrCodingConstants apply_hdr_coding_constants : packoffset(c000.x); +}; + +float4 main(precise noperspective float4 SV_Position: SV_Position) + : SV_Target { + float4 gamma_bt709 = back_buffer.Load(int3( + (int)(uint(apply_hdr_coding_constants.source_offset.x + SV_Position.x)), + (int)(uint(apply_hdr_coding_constants.source_offset.y + SV_Position.y)), + 0)); + + float3 pq_bt2020; + if (RENODX_TONE_MAP_TYPE == 0.f) { // Defaults: 2.41 gamma, 302 nits diffuse white + float linear_bt709_r = pow(gamma_bt709.x, apply_hdr_coding_constants.source_gamma); + float linear_bt709_g = pow(gamma_bt709.y, apply_hdr_coding_constants.source_gamma); + float linear_bt709_b = pow(gamma_bt709.z, apply_hdr_coding_constants.source_gamma); + float pq_scale = apply_hdr_coding_constants.pq_scaling * 10e-05f; + float bt2020_r_m1 = exp2(log2(mad(0.04331306740641594f, linear_bt709_b, mad(0.3292830288410187f, linear_bt709_g, (linear_bt709_r * 0.6274039149284363f))) * pq_scale) * 0.1593017578125f); + float bt2020_g_m1 = exp2(log2(mad(0.011362316086888313f, linear_bt709_b, mad(0.9195404052734375f, linear_bt709_g, (linear_bt709_r * 0.06909728795289993f))) * pq_scale) * 0.1593017578125f); + float bt2020_b_m1 = exp2(log2(mad(0.8955952525138855f, linear_bt709_b, mad(0.08801330626010895f, linear_bt709_g, (linear_bt709_r * 0.016391439363360405f))) * pq_scale) * 0.1593017578125f); + pq_bt2020.x = exp2(log2(((bt2020_r_m1 * 18.8515625f) + 0.8359375f) / ((bt2020_r_m1 * 18.6875f) + 1.0f)) * 78.84375f); + pq_bt2020.y = exp2(log2(((bt2020_g_m1 * 18.8515625f) + 0.8359375f) / ((bt2020_g_m1 * 18.6875f) + 1.0f)) * 78.84375f); + pq_bt2020.z = exp2(log2(((bt2020_b_m1 * 18.8515625f) + 0.8359375f) / ((bt2020_b_m1 * 18.6875f) + 1.0f)) * 78.84375f); + } else { + float3 linear_bt709; + if (RENODX_SDR_EOTF_EMULATION == 1.f) { + linear_bt709 = renodx::color::gamma::DecodeSafe(gamma_bt709.rgb, 2.2f); + } else if (RENODX_SDR_EOTF_EMULATION == 2.f) { + linear_bt709 = renodx::color::gamma::Decode(gamma_bt709.rgb, 2.41f); + } else { + linear_bt709 = renodx::color::srgb::Decode(gamma_bt709.rgb); + } + float3 linear_bt2020 = renodx::color::bt2020::from::BT709(linear_bt709); + pq_bt2020 = renodx::color::pq::Encode(linear_bt2020, RENODX_GRAPHICS_WHITE_NITS); + } + + return float4(pq_bt2020, 1.f); +} diff --git a/src/games/tlou2/addon.cpp b/src/games/tlou2/addon.cpp new file mode 100644 index 000000000..0542f6682 --- /dev/null +++ b/src/games/tlou2/addon.cpp @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2026 Musa Haji + * SPDX-License-Identifier: MIT + */ + +#define ImTextureID ImU64 + +#define DEBUG_LEVEL_0 +#define DEBUG_SLIDERS_OFF + +#include +#include + +#include + +#include "../../mods/shader.hpp" +#include "../../utils/date.hpp" +#include "../../utils/random.hpp" +#include "../../utils/settings.hpp" +#include "./shared.h" + +namespace { +ShaderInjectData shader_injection; + +renodx::mods::shader::CustomShaders custom_shaders = {__ALL_CUSTOM_SHADERS}; + +renodx::utils::settings::Settings settings = { + new renodx::utils::settings::Setting{ + .value_type = renodx::utils::settings::SettingValueType::TEXT, + .label = std::string("- Keep in-game Brightness slider at 0\n" + "- Keep in-game HUD Brightness slider at 5"), + .section = "About", + }, + new renodx::utils::settings::Setting{ + .key = "ToneMapType", + .binding = &shader_injection.tone_map_type, + .value_type = renodx::utils::settings::SettingValueType::INTEGER, + .default_value = 1.f, + .label = "Tone Mapper", + .section = "Tone Mapping", + .tooltip = "Sets the tone mapper type", + .labels = {"Vanilla", "RenoDX", "SDR"}, + }, + new renodx::utils::settings::Setting{ + .key = "ToneMapPeakNits", + .binding = &shader_injection.peak_white_nits, + .default_value = 1000.f, + .label = "Peak Brightness", + .section = "Tone Mapping", + .tooltip = "Sets the value of peak white in nits", + .min = 48.f, + .max = 10000.f, + .is_enabled = []() { return shader_injection.tone_map_type != 0.f; }, + .is_logarithmic = true, + }, + new renodx::utils::settings::Setting{ + .key = "ToneMapGameNits", + .binding = &shader_injection.diffuse_white_nits, + .default_value = 203.f, + .label = "Game Brightness", + .section = "Tone Mapping", + .tooltip = "Sets the value of 100% white in nits", + .min = 48.f, + .max = 500.f, + .is_enabled = []() { return shader_injection.tone_map_type != 0.f; }, + }, + new renodx::utils::settings::Setting{ + .key = "ToneMapUINits", + .binding = &shader_injection.graphics_white_nits, + .default_value = 203.f, + .label = "UI Brightness", + .section = "Tone Mapping", + .tooltip = "Sets the brightness of UI and HUD elements in nits.", + .min = 48.f, + .max = 500.f, + .is_enabled = []() { return shader_injection.tone_map_type != 0; }, + }, + new renodx::utils::settings::Setting{ + .key = "GammaCorrection", + .binding = &shader_injection.gamma_correction, + .value_type = renodx::utils::settings::SettingValueType::INTEGER, + .default_value = 1.f, + .label = "SDR EOTF Emulation", + .section = "Tone Mapping", + .tooltip = "Emulates a 2.2 EOTF", + .labels = {"Off", "2.2", "Vanilla (2.41)"}, + .is_enabled = []() { return shader_injection.tone_map_type != 0.f; }, + }, + new renodx::utils::settings::Setting{ + .key = "FxFilmGrainType", + .binding = &shader_injection.custom_film_grain_type, + .value_type = renodx::utils::settings::SettingValueType::INTEGER, + .default_value = 1.f, + .label = "Film Grain Type", + .section = "Effects", + .labels = {"Vanilla", "Perceptual"}, + .is_enabled = []() { return shader_injection.tone_map_type != 0.f; }, + }, + new renodx::utils::settings::Setting{ + .key = "FxGrainStrength", + .binding = &shader_injection.custom_grain_strength, + .default_value = 50.f, + .label = "FilmGrain", + .section = "Effects", + .max = 100.f, + .is_enabled = []() { return shader_injection.tone_map_type != 0.f && shader_injection.custom_film_grain_type != 0.f; }, + .parse = [](float value) { return value * 0.02f; }, + }, + new renodx::utils::settings::Setting{ + .value_type = renodx::utils::settings::SettingValueType::BUTTON, + .label = "Reset All", + .section = "Options", + .group = "button-line-1", + .on_change = []() { + for (auto* setting : settings) { + if (setting->key.empty()) continue; + if (!setting->can_reset) continue; + renodx::utils::settings::UpdateSetting(setting->key, setting->default_value); + } + }, + }, + new renodx::utils::settings::Setting{ + .value_type = renodx::utils::settings::SettingValueType::BUTTON, + .label = "RenoDX Discord", + .section = "Links", + .group = "button-line-2", + .tint = 0x5865F2, + .on_change = []() { + renodx::utils::platform::LaunchURL("https://discord.gg/", "t9v7wx9NTD"); + }, + }, + new renodx::utils::settings::Setting{ + .value_type = renodx::utils::settings::SettingValueType::BUTTON, + .label = "HDR Den Discord", + .section = "Links", + .group = "button-line-2", + .tint = 0x5865F2, + .on_change = []() { + renodx::utils::platform::LaunchURL("https://discord.gg/", "a7HECzaPG7"); + }, + }, + new renodx::utils::settings::Setting{ + .value_type = renodx::utils::settings::SettingValueType::BUTTON, + .label = "More Mods", + .section = "Links", + .group = "button-line-2", + .tint = 0x2B3137, + .on_change = []() { + renodx::utils::platform::LaunchURL("https://github.com/clshortfuse/renodx/wiki/Mods"); + }, + }, + new renodx::utils::settings::Setting{ + .value_type = renodx::utils::settings::SettingValueType::BUTTON, + .label = "Github", + .section = "Links", + .group = "button-line-2", + .tint = 0x2B3137, + .on_change = []() { + renodx::utils::platform::LaunchURL("https://github.com/clshortfuse/renodx"); + }, + }, + new renodx::utils::settings::Setting{ + .value_type = renodx::utils::settings::SettingValueType::BUTTON, + .label = "Musa's Ko-Fi", + .section = "Links", + .group = "button-line-3", + .tint = 0xFF5A16, + .on_change = []() { renodx::utils::platform::LaunchURL("https://ko-fi.com/musaqh"); }, + }, + new renodx::utils::settings::Setting{ + .value_type = renodx::utils::settings::SettingValueType::BUTTON, + .label = "ShortFuse's Ko-Fi", + .section = "Links", + .group = "button-line-3", + .tint = 0xFF5A16, + .on_change = []() { renodx::utils::platform::LaunchURL("https://ko-fi.com/shortfuse"); }, + }, + new renodx::utils::settings::Setting{ + .value_type = renodx::utils::settings::SettingValueType::TEXT, + .label = std::string("Build: ") + renodx::utils::date::ISO_DATE_TIME, + .section = "About", + }, +}; + +void OnPresetOff() { + renodx::utils::settings::UpdateSettings({ + {"ToneMapType", 0.f}, + {"ToneMapPeakNits", 10000.f}, + {"ToneMapGameNits", 302.f}, + {"ToneMapUINits", 302.f}, + {"GammaCorrection", 2.f}, + {"FxFilmGrainType", 0.f}, + {"FxGrainStrength", 50.f}, + }); +} + +bool fired_on_init_swapchain = false; + +void OnInitSwapchain(reshade::api::swapchain* swapchain, bool resize) { + if (fired_on_init_swapchain) return; + fired_on_init_swapchain = true; + auto peak = renodx::utils::swapchain::GetPeakNits(swapchain); + if (peak.has_value()) { + settings[2]->default_value = peak.value(); + settings[2]->can_reset = true; + } +} + +bool initialized = false; + +} // namespace + +// NOLINTBEGIN(readability-identifier-naming) + +extern "C" __declspec(dllexport) constexpr const char* NAME = "RenoDX"; +extern "C" __declspec(dllexport) constexpr const char* DESCRIPTION = "RenoDX for The Last of Us Part II"; + +// NOLINTEND(readability-identifier-naming) + +BOOL APIENTRY DllMain(HMODULE h_module, DWORD fdw_reason, LPVOID lpv_reserved) { + switch (fdw_reason) { + case DLL_PROCESS_ATTACH: + if (!reshade::register_addon(h_module)) return FALSE; + + renodx::mods::shader::on_create_pipeline_layout = [](reshade::api::device* device, auto) { + return device->get_api() == reshade::api::device_api::d3d12; + }; + + renodx::mods::shader::on_init_pipeline_layout = [](reshade::api::device* device, auto, auto) { + return device->get_api() == reshade::api::device_api::d3d12; + }; + + renodx::utils::random::binds.push_back(&shader_injection.custom_random); // film grain + + if (!initialized) { + renodx::mods::shader::force_pipeline_cloning = true; + // renodx::mods::shader::allow_multiple_push_constants = true; + renodx::mods::shader::expected_constant_buffer_space = 50; + renodx::mods::shader::expected_constant_buffer_index = 13; + + initialized = true; + } + reshade::register_event(OnInitSwapchain); // detect peak nits + + break; + case DLL_PROCESS_DETACH: + reshade::unregister_event(OnInitSwapchain); // detect peak nits + + reshade::unregister_addon(h_module); + break; + } + + renodx::utils::random::Use(fdw_reason); // film grain + renodx::utils::settings::Use(fdw_reason, &settings, &OnPresetOff); + renodx::mods::shader::Use(fdw_reason, custom_shaders, &shader_injection); + + return TRUE; +} \ No newline at end of file diff --git a/src/games/tlou2/common.hlsli b/src/games/tlou2/common.hlsli new file mode 100644 index 000000000..0df26f439 --- /dev/null +++ b/src/games/tlou2/common.hlsli @@ -0,0 +1,140 @@ +#include "./shared.h" + +namespace tlou2 { +namespace tonemap { + +// Native curve: +// A * x + B +// T(x) = ------------------ + E +// x * (x + C) + D +#define TLOU2_TONEMAP_APPLY_GENERATOR(T) \ + T Apply(T x, float A, float B, float C, float D, float E) { \ + return ((A * x) + B) / ((x * (x + C)) + D) + E; \ + } + +TLOU2_TONEMAP_APPLY_GENERATOR(float) +TLOU2_TONEMAP_APPLY_GENERATOR(float3) +#undef TLOU2_TONEMAP_APPLY_GENERATOR + +float Derivative(float x, float A, float B, float C, float D) { + float Denominator = (x * (x + C)) + D; + float Numerator = (-A * x * x) - (2.f * B * x) + (A * D) - (B * C); + return Numerator / (Denominator * Denominator); +} + +// T''(x) = 2 * (A * (-C*D - 3*D*x + x^3) +// + B * (C^2 + 3*C*x - D + 3*x^2)) +// / (D + x * (C + x))^3 +// Find the largest non-negative root of its cubic numerator. The additive +// offset E does not affect either derivative. +float FindInflectionPoint(float A, float B, float C, float D) { + float A3 = A; + float A2 = 3.f * B; + float A1 = 3.f * ((B * C) - (A * D)); + float A0 = (B * C * C) - (A * C * D) - (B * D); + float A3Rcp = 1.f / A3; + + float P = (3.f * A1 * A3 - A2 * A2) / (3.f * A3 * A3); + float Q = (27.f * A0 * A3 * A3 - 9.f * A2 * A1 * A3 + 2.f * A2 * A2 * A2) + / (27.f * A3 * A3 * A3); + float Delta = (Q * Q) / 4.f + (P * P * P) / 27.f; + float Root; + + if (Delta >= 0.f) { + float SqrtDelta = sqrt(Delta); + Root = renodx::math::Cbrt(-Q * 0.5f + SqrtDelta) + renodx::math::Cbrt(-Q * 0.5f - SqrtDelta); + } else { + float PositivePOver3 = -P / 3.f; + float Angle = acos(clamp((-Q * 0.5f) * rsqrt(PositivePOver3 * PositivePOver3 * PositivePOver3), -1.f, 1.f)); + Root = 2.f * sqrt(PositivePOver3) * cos(Angle / 3.f); + } + + return max(Root - (A2 * A3Rcp / 3.f), 0.f); +} + +#define TLOU2_TONEMAP_APPLY_EXTENDED_GENERATOR(T) \ + T ApplyExtended(T x, float A, float B, float C, float D, float E, inout float InflectionY) { \ + float PivotX = FindInflectionPoint(A, B, C, D); \ + InflectionY = Apply(PivotX, A, B, C, D, E); \ + float Slope = Derivative(PivotX, A, B, C, D); \ + T Extended = InflectionY + (Slope * (x - PivotX)); \ + return renodx::math::Select(x > PivotX, Extended, Apply(x, A, B, C, D, E)); \ + } \ + \ + T ApplyExtended(T x, float A, float B, float C, float D, float E) { \ + float InflectionY; \ + return ApplyExtended(x, A, B, C, D, E, InflectionY); \ + } + +TLOU2_TONEMAP_APPLY_EXTENDED_GENERATOR(float) +TLOU2_TONEMAP_APPLY_EXTENDED_GENERATOR(float3) +#undef TLOU2_TONEMAP_APPLY_EXTENDED_GENERATOR + +} // namespace tonemap +} // namespace tlou2 + +/// Identity through anchor to every derivative; then approaches peak +/// monotonically and concave down. Requires anchor < peak and compression_strength >= 1. +#define APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(T) \ + T ApplyAnchoredCInfinityShoulder(T color, T peak, T anchor, float compression_strength) { \ + T shoulder_range = peak - anchor; \ + T distance_from_anchor = max(color - anchor, (T)0.f); \ + T flat_weight = exp2(-shoulder_range / (compression_strength * distance_from_anchor)); \ + T response_denominator = mad(distance_from_anchor, flat_weight, shoulder_range); \ + return mad(shoulder_range, distance_from_anchor / response_denominator, color - distance_from_anchor); \ + } + +APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float) +APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float3) +#undef APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR + +float ApplyAnchoredCInfinityShoulderMaxChannelScale(float3 color, float peak = 1.f, float anchor = 0.18f, float compression_strength = 100.f) { + float max_channel = renodx::math::Max(abs(color)); + float compressed_max = ApplyAnchoredCInfinityShoulder(max_channel, peak, anchor, compression_strength); + return renodx::math::DivideSafe(compressed_max, max_channel, 1.f); +} + +float ApplyAnchoredCInfinityShoulderLuminanceScale(float3 color, float peak = 1.f, float anchor = 0.18f, float compression_strength = 100.f) { + float luminance = renodx::color::yf::from::BT709(color); + float compressed_luminance = ApplyAnchoredCInfinityShoulder(luminance, peak, anchor, compression_strength); + return renodx::math::DivideSafe(compressed_luminance, luminance, 1.f); +} + +namespace tlou2 { +namespace post_post_processing { + +// Final post-post-processing adjustment hook. The input and output are +// extended-range gamma-encoded BT.709; decode and re-encode here before +// performing operations that require linear light. +float3 ApplyFinalOutput(float3 color, float2 texcoord) { + if (RENODX_TONE_MAP_TYPE != 0.f) { + if (RENODX_GAMMA_CORRECTION == 1.f) { + color = renodx::color::gamma::DecodeSafe(color, 2.2f); + } else if (RENODX_GAMMA_CORRECTION == 2.f) { + color = renodx::color::gamma::DecodeSafe(color, 2.41f); + } else { + color = renodx::color::srgb::DecodeSafe(color); + } + if (RENODX_TONE_MAP_TYPE == 1.f) { + float peak_ratio = RENODX_PEAK_WHITE_NITS / RENODX_DIFFUSE_WHITE_NITS; + color = ApplyAnchoredCInfinityShoulder(color, peak_ratio, 0.4f, 1.f); + } + if (CUSTOM_GRAIN_TYPE != 0.f) { + color = renodx::effects::ApplyFilmGrain(color, texcoord, CUSTOM_RANDOM, CUSTOM_GRAIN_STRENGTH * 0.03f); + } + + color *= RENODX_DIFFUSE_WHITE_NITS / RENODX_GRAPHICS_WHITE_NITS; + + if (RENODX_GAMMA_CORRECTION == 1.f) { + color = renodx::color::gamma::EncodeSafe(color, 2.2f); + } else if (RENODX_GAMMA_CORRECTION == 2.f) { + color = renodx::color::gamma::EncodeSafe(color, 2.41f); + } else { + color = renodx::color::srgb::EncodeSafe(color); + } + } + return color; +} + +} // namespace post_processing +} // namespace tlou2 diff --git a/src/games/tlou2/metadata.json b/src/games/tlou2/metadata.json new file mode 100644 index 000000000..589afb631 --- /dev/null +++ b/src/games/tlou2/metadata.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://cdn.jsdelivr.net/gh/clshortfuse/renodx/renodx-metadata-schema.json", + "id": "tlou2", + "title": "The Last of Us Part II Remastered", + "maintainers": [ + "Musa" + ], + "summary": "Adds configurable HDR tone mapping and perceptual film grain to The Last of Us Part II Remastered.", + "tags": [ + "hdr", + "tone-mapping", + "film-grain" + ], + "status": "beta", + "header": "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/2531310/header.jpg", + "notes": [ + "Beta: functionality is incomplete and still under development.", + "Keep the in-game Brightness slider at 0 and HUD Brightness slider at 5.", + "For HDR output, enable HDR in Windows and in game." + ], + "deploy": { + "steam_appid": 2531310, + "api": "d3d12", + "architecture": [ + "x64" + ] + } +} \ No newline at end of file diff --git a/src/games/tlou2/shared.h b/src/games/tlou2/shared.h new file mode 100644 index 000000000..703d02082 --- /dev/null +++ b/src/games/tlou2/shared.h @@ -0,0 +1,38 @@ +#ifndef SRC_TLOU2_SHARED_H_ +#define SRC_TLOU2_SHARED_H_ + +// Must be 32bit aligned +// Should be 4x32 +struct ShaderInjectData { + float tone_map_type; + float peak_white_nits; + float diffuse_white_nits; + float graphics_white_nits; + float gamma_correction; + + float custom_random; + float custom_film_grain_type; + float custom_grain_strength; +}; + +#ifndef __cplusplus +cbuffer shader_injection : register(b13, space50) { + ShaderInjectData shader_injection : packoffset(c0); +} + +#define RENODX_TONE_MAP_TYPE shader_injection.tone_map_type +#define RENODX_PEAK_WHITE_NITS shader_injection.peak_white_nits +#define RENODX_DIFFUSE_WHITE_NITS shader_injection.diffuse_white_nits +#define RENODX_GRAPHICS_WHITE_NITS shader_injection.graphics_white_nits +#define RENODX_SDR_EOTF_EMULATION shader_injection.gamma_correction + +#define CUSTOM_RANDOM shader_injection.custom_random +#define CUSTOM_GRAIN_TYPE shader_injection.custom_film_grain_type +#define CUSTOM_GRAIN_STRENGTH shader_injection.custom_grain_strength + +#define CUSTOM_GRADE_STRENGTH 1.f + +#include "../../shaders/renodx.hlsl" +#endif + +#endif // SRC_TLOU2_SHARED_H_ \ No newline at end of file From a8491f452edf93169cf0fe07ad6aa57ccc987b02 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Thu, 20 Aug 2026 23:54:49 -0400 Subject: [PATCH 17/22] feat(tlou2): adjust tm for lut sampling --- src/games/tlou2/CS_PrePostProcessing_0x535F90FB.cs_6_0.hlsl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/games/tlou2/CS_PrePostProcessing_0x535F90FB.cs_6_0.hlsl b/src/games/tlou2/CS_PrePostProcessing_0x535F90FB.cs_6_0.hlsl index c175370a6..694e57825 100644 --- a/src/games/tlou2/CS_PrePostProcessing_0x535F90FB.cs_6_0.hlsl +++ b/src/games/tlou2/CS_PrePostProcessing_0x535F90FB.cs_6_0.hlsl @@ -848,7 +848,7 @@ void main( const float shaper_input_scale = g_prePostProcessingShaderConst_000.PostProcessingShaderConst_688; float scale = 1.f; if (RENODX_TONE_MAP_TYPE == 1.f) { - scale = ApplyAnchoredCInfinityShoulderLuminanceScale(graded, tm_peak, anchor); + scale = ApplyAnchoredCInfinityShoulderLuminanceScale(graded, tm_peak, 0.5f); } graded *= scale; From 1050e75e86befbbd947ecc4fb84750fe1f0882d7 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Fri, 21 Aug 2026 16:00:35 -0400 Subject: [PATCH 18/22] feat(deathstranding2): set peak nits max to 10k --- src/games/deathstranding2/addon.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/games/deathstranding2/addon.cpp b/src/games/deathstranding2/addon.cpp index 41a259d6a..17f980b96 100644 --- a/src/games/deathstranding2/addon.cpp +++ b/src/games/deathstranding2/addon.cpp @@ -43,8 +43,9 @@ renodx::utils::settings::Settings settings = { .section = "Tone Mapping", .tooltip = "Sets the value of peak white in nits", .min = 48.f, - .max = 4000.f, + .max = 10000.f, .is_enabled = []() { return shader_injection.tone_map_type != 0.f; }, + .is_logarithmic = true, }, new renodx::utils::settings::Setting{ .key = "ToneMapGameNits", From 0c0da6fb1522c2069fe1adfe476b8a82eed91fec Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Sun, 23 Aug 2026 01:12:04 -0400 Subject: [PATCH 19/22] feat(elitedangerous): use psychov30 --- src/games/elitedangerous/common.hlsli | 21 + .../tonemap/psychov/customtest30.hlsli | 2122 +++++++++++++++++ .../tonemap/psychov25/customtest25.hlsli | 637 ----- .../elitedangerous/tonemap/tonemap.hlsli | 14 +- 4 files changed, 2151 insertions(+), 643 deletions(-) create mode 100644 src/games/elitedangerous/tonemap/psychov/customtest30.hlsli delete mode 100644 src/games/elitedangerous/tonemap/psychov25/customtest25.hlsli diff --git a/src/games/elitedangerous/common.hlsli b/src/games/elitedangerous/common.hlsli index 8a9fa8fb5..5b675af77 100644 --- a/src/games/elitedangerous/common.hlsli +++ b/src/games/elitedangerous/common.hlsli @@ -40,4 +40,25 @@ float3 FinalizeOutput(float3 color) { return color; } +/// Identity through anchor to every derivative; then approaches peak +/// monotonically and concave down. Requires anchor < peak and compression_strength >= 1. +#define APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(T) \ + T ApplyAnchoredCInfinityShoulder(T color, T peak, T anchor, float compression_strength) { \ + T shoulder_range = peak - anchor; \ + T distance_from_anchor = max(color - anchor, (T)0.f); \ + T flat_weight = exp2(-shoulder_range / (compression_strength * distance_from_anchor)); \ + T response_denominator = mad(distance_from_anchor, flat_weight, shoulder_range); \ + return mad(shoulder_range, distance_from_anchor / response_denominator, color - distance_from_anchor); \ + } + +APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float) +APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float3) +#undef APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR + +float ApplyAnchoredCInfinityShoulderMaxChannelScale(float3 color, float peak, float anchor, float compression_strength) { + float max_channel = renodx::math::Max(abs(color)); + float compressed_max = ApplyAnchoredCInfinityShoulder(max_channel, peak, anchor, compression_strength); + return renodx::math::DivideSafe(compressed_max, max_channel, 1.f); +} + #endif // RENODX_ELITEDANGEROUS_COMMON_HLSLI_ \ No newline at end of file diff --git a/src/games/elitedangerous/tonemap/psychov/customtest30.hlsli b/src/games/elitedangerous/tonemap/psychov/customtest30.hlsli new file mode 100644 index 000000000..8e5e0ee0b --- /dev/null +++ b/src/games/elitedangerous/tonemap/psychov/customtest30.hlsli @@ -0,0 +1,2122 @@ +#ifndef PSYCHOV_CUSTOMTEST30_HLSLI_ +#define PSYCHOV_CUSTOMTEST30_HLSLI_ + +#include "../../common.hlsli" + +/* + * Copyright (C) 2026 Carlos Lopez + * SPDX-License-Identifier: MIT + */ + +namespace renodx { +namespace tonemap { +namespace psychov { + +// PsychoV30: selected Mean-A2 / physiological-Yf response +// ========================================================= +// +// Signal contract +// --------------- +// Input and output are direct linear-light BT.709 RGB with D65 white. +// `peak_value` expresses display peak in reference-white-relative units. +// The target volume is the normalized linear BT.709 RGB cube for mode 0 or +// the normalized linear BT.2020 RGB cube for every other mode. Output remains +// represented as linear BT.709 even when the constrained target is BT.2020. +// +// Scientific basis and engineering stages +// --------------------------------------- +// - RGB is transformed to the Stockman/CVRL two-degree LMS basis. +// - The achromatic coordinate is physiological Yf from the +// Stockman-Sharpe LMS-to-XfYfZf transform: +// +// Yf = cL * L + cM * M +// +// Yf is the relative observer coordinate formed by weighted L and M cone +// responses. +// - Purity is direct LMS interpolation toward the adapting neutral while +// retaining the adaptation-relative Yf coordinate. It does not require +// MacLeod-Boynton coordinates or short-wave weighting. +// - CIE 170-2 weighted MacLeod-Boynton chromaticity is isolated to the signed +// fallback's source-boundary continuation. Its metric remains a successor +// candidate for replacement by a coordinate consistent with the A2 path. +// - Adaptation-relative cone ratios are consistent with the early-cone +// background-normalization framework discussed by Stockman and Brainard. +// - The finite endpoint, Mean-A2 direction, and locked-direction target-cube +// projection are the rendering-response and device-mapping stages. +// +// Positive-cone response +// ---------------------- +// Let q_i = LMS_i / anchor_in_i, P_i = peak_value * D65_LMS_i, +// p = contrast * cone_response_exponent, and +// k_i = pow(anchor_out_i / P_i, h). Test30 evaluates: +// +// beta_i = p * h / (1 - k_i) +// e_i = 1 / (1 + (1 / k_i - 1) * pow(q_i, -beta_i)) +// u_i = pow(e_i, 1 / h) +// +// The conceptual response is P_i * u_i. The reciprocal form remains finite +// when the corresponding positive power overflows. It preserves +// anchor_in -> anchor_out, has +// adaptation-point logarithmic slope p, approaches zero as q -> 0, and +// approaches selected peak white as q -> infinity. +// +// Mean-A2 direction +// ----------------- +// A2 denotes this shader's internal orthonormal cone-opponent plane. For +// normalized cone load u: +// +// X = (uL - uM) / sqrt(2) +// C0 = (uL + uM + uS) / sqrt(3) +// Z = (2 * uS - uL - uM) / sqrt(6) +// +// Source A2 direction comes from adaptation-relative q; response A2 direction +// comes from peak-relative post-G u. Normalizing and adding the two directions +// gives their exact angular bisector. Test30 retains the response A2 radius +// and C0, replacing direction only. +// +// Exact target solve +// ------------------ +// With D65 Yf fractions alphaL + alphaM = 1, the normalized physiological +// coordinate represented by (X, C0, Z) is: +// +// A = C0 / sqrt(3) + (alphaL - alphaM) * X / sqrt(2) +// - Z / sqrt(6) +// +// Target RGB is affine in A, X, and Z. Locking the authored A2 direction and +// scaling (X, Z) by s makes all lower/upper RGB-cube planes and the response +// Yf ceiling linear inequalities in (C0, s). The feasible set is a convex +// polygon. Segment projection uses +// +// distance^2 = delta_C0^2 + (X^2 + Z^2) * delta_s^2 +// +// which is exactly Euclidean distance in the original (X, C0, Z) coordinate +// under the locked direction. Full compression analytically finds the nearest +// point inside this fixed-direction model's four-edge feasible polygon. +// +// Cone states containing zero or negative values use the separately documented +// signed linear-A2 fallback with analytic target RGB-cube ray support. +// +// PsychoV research record +// ======================= +// +// This record is carried forward through PsychoV tests so each successor keeps +// the scientific rationale, source attribution, selected implementation, and +// next research directions beside the shader that ships. Test30 extends the +// Test17-Test25 record with Mean-A2 response authoring and an exact +// fixed-direction device-cube projection. +// +// Research objective and system boundary +// -------------------------------------- +// PsychoV studies two coupled systems: +// +// 1. Observer-side organization: receptor coordinates, adaptation-relative +// cone state, achromatic and opponent coordinates, response shaping, and +// visibility/gain mechanisms supported by vision research. +// 2. Device-hull mapping: a joint tone, direction, and target-volume solve +// constrained by display primaries, white, reference-white scale, and peak. +// +// Test30's selected rendering pipeline is: +// +// linear-light BT.709 +// -> Stockman/CVRL LMS +// -> scalar physiological-Yf grading +// -> adaptation-relative LMS purity +// -> adaptation-relative common cone power +// -> anchor-matched finite per-cone G +// -> Mean-A2 direction with post-G radius and C0 +// -> exact fixed-direction target RGB-cube/Yf projection +// -> linear-light BT.709 representation +// +// The caller supplies the current adaptation and desired output-background +// anchors. The runtime signal is reference-white-relative. Absolute retinal +// scale, local/temporal adaptation estimation, visibility thresholds, and +// cortical gain form explicit successor-test research directions below. +// +// 1) Receptor basis and observer coordinates +// ------------------------------------------ +// Brainard's Colorimetry chapter supplies the cone-stage/color-match +// foundation. Stockman and Brainard build on that receptor basis for +// first-site and second-site adaptation. Test30 transforms linear-light +// BT.709 through XYZ to the Stockman/CVRL two-degree LMS fit. +// +// Sources: +// https://color2.psych.upenn.edu/brainard/papers/Brainard_Stockman_Colorimetry.pdf +// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf +// +// The published Stockman-Sharpe fundamentals include standard prereceptoral +// lens and macular filtering for an average, mainly foveal two-degree observer. +// CVRL documents the ocular-media and macular-pigment filters, their strong +// short-wavelength absorption, and their observer variation. Successor tests +// can expose age, field size, eccentricity, lens, and macular assumptions when +// personalized observer transforms become an input. +// +// Sources: +// http://www.cvrl.org/background.htm +// http://www.cvrl.org/database/text/intros/intromaclens.htm +// +// Test30's selected positive-cone path carries physiological Yf: +// +// physiological Yf = cL * L + cM * M +// +// where cL and cM come directly from the Yf row of the base +// Stockman-Sharpe LMS-to-XfYfZf transform. The selected purity and response +// stages operate directly in LMS and Yf and do not use an S-cone weight. +// +// The signed fallback separately retains CIE 170-2 weighted +// MacLeod-Boynton chromaticity for source-boundary continuation: +// +// l = Lw / (Lw + Mw) +// s = Sw / (Lw + Mw) +// +// MacLeod-Boynton (1979) supplies the classic weighted-cone chromaticity +// construction. CVRL/CIE physiological data and repository constants supply +// the exact coefficients used here. Psychtoolbox documents a practical +// CIE-based LMS-to-MacLeod-Boynton implementation. Webster and Leonard use a +// modified MB framework for adaptation norms. Mantiuk et al. describe a +// practical LMS scaling whose L+M sum carries an achromatic coordinate. +// +// Sources: +// http://www.cvrl.org/ciexyzpr.htm +// https://psychtoolbox.org/docs/LMSToMacBoyn +// MacLeod & Boynton, JOSA 1979, doi:10.1364/JOSA.69.001183 +// Webster & Leonard, JOSA A 2008, doi:10.1364/JOSAA.25.002817 +// https://pmc.ncbi.nlm.nih.gov/articles/PMC2657039/ +// https://www.cl.cam.ac.uk/~rkm38/pdfs/mantiuk2020practical_csf.pdf +// +// 2) Early cone adaptation +// ------------------------ +// Stockman and Brainard express first-site L-cone contrast as +// +// C_L = delta_L / (L_b + L_0) +// +// with corresponding M- and S-cone forms. Equivalently, the background sets +// the cone gain: +// +// g_L = 1 / (L_b + L_0) +// g_L * (L - L_b) = delta_L / (L_b + L_0) +// +// Test30 receives caller-authored adaptation LMS as `anchor_in` and uses +// q_i = LMS_i / anchor_in_i as its static background-relative state. This +// preserves the architecture of cone-specific normalization while keeping +// adaptation policy in the caller. A successor with image/retinal context can +// estimate L_b, M_b, S_b and semi-saturation L_0, M_0, S_0 over space and time. +// +// Stockman et al. describe first-site regulation across light levels and the +// transition toward bleaching-supported high-light sensitivity regulation. +// Source: JOV 2006, doi:10.1167/6.11.5. +// +// Webster and Leonard distinguish a response norm, the adapting level that +// leaves white judgments unbiased, from a perceptual norm, the stimulus that +// appears white. Their experiments found close tracking between these norms. +// PsychoV uses adapted-background reference for the directly carried cone +// state and retains response/perceptual norms as higher-level interpretations +// of the current neutral coding state. +// Source: JOSA A 2008, doi:10.1364/JOSAA.25.002817. +// +// CVRL documents observing-condition and chromatic-adaptation dependence in +// physiological luminosity functions, while cone spectral sensitivities stay +// stable through ordinary adaptation levels. This supports carrying Yf with +// the current adapted observer state. +// Source: http://www.cvrl.org/database/text/intros/introvl.htm +// +// 2a) Dim cone-noise extension +// ---------------------------- +// Cone-mediated detection reaches a quantal/transduction-noise regime before +// rod-dominated vision. Approximate De Vries-Rose behavior gives threshold +// cone contrast a log-log slope near -0.5 against retinal illuminance. Higher +// adaptation levels approach Weber-like behavior, where threshold contrast is +// approximately constant relative to background. A calibrated successor can +// use retinal illuminance and cone-specific noise to attenuate scene +// differences below this visibility floor before postreceptoral processing. +// +// Stockman and Brainard discuss the range where cone-contrast coordinates +// approach Weber behavior. Angueyra and Rieke measure primate-cone +// phototransduction noise and its contribution to the dim-light threshold. +// Sources: +// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf +// Angueyra & Rieke, Nature Neuroscience 2013, doi:10.1038/nn.3534 +// https://pmc.ncbi.nlm.nih.gov/articles/PMC3815624/ +// +// 2b) High-light bleaching extension +// ----------------------------------- +// A retinal-illuminance-calibrated successor can represent steady-state cone +// pigment availability with the Rushton-Henry form +// +// p_available(I) = 1 / (1 + I / I0) +// +// and the complementary bleached fraction +// +// p_bleached(I) = I / (I + I0), I0 approximately 10^4.3 Td. +// +// Physiological placement follows adaptation-state definition and precedes +// postreceptoral opponent response and pooled gain. A rendering realization +// can apply availability to cone excursions around the adapted-white anchor, +// approaching equal white at the carried achromatic level as availability +// approaches zero. Test30's selected highlight endpoint is the finite-G +// equation documented above; the bleaching equations remain a calibrated +// successor path tied to retinal units. +// +// Sources: +// Stockman et al., JOV 2006, doi:10.1167/6.11.5 +// Stockman et al., JOV 2018, doi:10.1167/18.6.12 +// Rushton & Henry, Vision Research 1968, +// doi:10.1016/0042-6989(68)90040-0 +// http://www.cvrl.org/database/text/intros/introbleaches.htm +// +// 3) Background-normalized opponent organization +// ------------------------------------------------ +// Test30 applies adaptation-relative purity directly in LMS, then constructs +// A2 as an orthonormal decomposition of the three adaptation/peak-normalized +// cone loads. A2 supplies an exact Euclidean metric and sixfold cone-axis +// geometry for Mean-A2 direction authoring and target projection. The signed +// fallback still uses weighted MacLeod-Boynton chromaticity for one +// source-boundary trace; this is not part of the selected positive path and +// should be revisited alongside a fitted ACC/DKL or A2-consistent fallback. +// +// 4) Saturating response research +// ------------------------------- +// Michaelis-Menten/Naka-Rushton response families provide receptor and +// early-cortical contrast models; supersaturating forms capture additional +// cortical response shapes. Peirce analyzes how saturating and supersaturating +// contrast response functions affect visual-cortex interpretation. +// Source: Peirce, JOV 2007, doi:10.1167/7.6.13. +// +// Test30 selects the anchor-preserving finite per-cone G above. Its reciprocal +// parameterization fixes the caller's input/output anchor, logarithmic slope, +// and selected peak endpoint. This creates a controlled rendering response for +// direct comparison with future fitted receptor or cortical response models. +// +// 5) ON/OFF response research +// --------------------------- +// Retinal ON and OFF channels separate increments and decrements around an +// adapted background. Schiller reviews their parallel visual-system roles. +// Yu, Turner, Baudin, and Rieke show that cone adaptation and downstream +// nonlinearities can combine unexpectedly for natural-image structure, +// motivating natural-image validation of any explicit polarity split. +// +// Rahimi-Nasrabadi et al. validate an ONOFF image algorithm on calibrated +// grayscale images and propose color extension through a scalar lightness +// dimension. PsychoV's scalar-Yf highlight/shadow grade follows the analogous +// engineering principle of applying polarity-shaped grades to one achromatic +// coordinate while retaining cone ratios. +// +// Sources: +// Schiller, Trends Neurosci 1992, +// doi:10.1016/0166-2236(92)90017-3 +// Yu et al., eLife 2022, doi:10.7554/eLife.70611 +// Rahimi-Nasrabadi et al., Cell Reports 2021, +// doi:10.1016/j.celrep.2021.108692 +// +// Test30's automatic finite-G curve uses a centered static log-range prior. +// A successor ON/OFF stage can fit separate increment/decrement responses and +// preserve the same adaptation anchor and device-hull coupling. +// +// 6) Pooled divisive gain research +// -------------------------------- +// Divisive normalization models pooled neural response as a channel drive +// divided by a semi-saturated measure of neighboring/population activity. +// This supplies a research path for coupled achromatic/opponent energy, +// spatial context, and contrast-dependent gain after polarity processing. +// +// Sources: +// Heeger, Visual Neuroscience 1992, +// doi:10.1017/S0952523800009640 +// Carandini & Heeger, Nature Reviews Neuroscience 2012, +// doi:10.1038/nrn3136 +// Bun & Horwitz, Color Research & Application 2023, +// doi:10.1002/col.22903 +// +// A successor implementation can add fitted pooling neighborhoods and +// semi-saturation constants after a selected opponent/ON-OFF stage. Test30 +// supplies a static per-pixel response baseline for that comparison. +// +// 7) Unified device-hull tone and gamut mapping +// --------------------------------------------- +// Display mapping is constrained by the complete target RGB volume. In +// normalized target coordinates this is +// +// 0 <= R,G,B <= 1. +// +// Lower and upper channel planes, faces, edges, corners, and neutral-axis +// capacity participate in one device-hull problem. High-purity directions can +// reach a target face at a lower achromatic level than D65, so a joint solve +// trades radial opponent distance and achromatic coordinate according to the +// selected metric. ITU-R BT.2408 supplies the practical HDR Reference White +// framing that keeps reference/diffuse white distinct from display peak. +// Source: https://www.itu.int/pub/R-REP-BT.2408 +// +// Test30 fixes the Mean-A2 authored direction and projects exactly in the full +// orthonormal (X,C0,Z) metric over the resulting convex target-cube/Yf polygon. +// This extends Test25's numerical ray support into an analytic nearest-point +// solve for the selected direction. BT.709 and BT.2020 modes share the same +// D65 cone normalization and use their respective complete RGB cubes. +// +// A successor sectional solve can search multiple directions within the +// active cone-axis sextant, include a fitted postreceptoral metric, and compare +// face/edge/interior candidates. Mean-A2 remains the preferred authored +// trajectory candidate and Test30 remains the exact fixed-direction baseline. +// +// 7a) Hue-objective research inside the hull solve +// ------------------------------------------------ +// Mizokami et al. and O'Neil et al. study a functional account of the Abney +// effect based on an equivalent Gaussian spectral peak. For short and medium +// wavelengths, the equivalent-peak parameter can provide a hue objective as +// purity changes. A future spectral precomputation can map weighted-LMS/MB +// chromaticity to mu_eq and evaluate mu_eq alongside A2/ACC direction during +// target-hull optimization while carrying Yf separately. +// +// Sources: +// Mizokami et al., JOV 2006, doi:10.1167/6.9.12 +// O'Neil et al., JOSA A 2012, doi:10.1364/JOSAA.29.00A165 +// +// 7b) Simultaneous-range auto-compression +// --------------------------------------- +// `compression == 0` uses a static centered simultaneous-range reference: +// +// side_range = reference_range_log10 / 2 +// h = max(side_range / log10(peak_Yf / anchor_Yf), 1) +// +// Kunkel and Reinhard report approximately 3.7 log10 units under their adapted +// test conditions. Jiang and Fairchild directly measured bright/dark +// simultaneous range on an Apple Pro Display XDR: approximately 3.3 log10 for +// the average observer and 3.47 for one observer at 1600 cd/m^2 with a +// 3.4-degree stimulus. Their fitted maxima were approximately 3.24 at +// 452 cd/m^2 and 3.40 at 1600 cd/m^2. These condition-dependent measurements +// motivate future display-, surround-, field-size-, and glare-aware range +// selection. Test30 keeps 3.7 as its static baseline for direct continuity +// with Test22-Test25. +// +// Sources: +// Kunkel & Reinhard, APGV 2010, doi:10.1145/1836248.1836251 +// Jiang & Fairchild, JIST 2021, +// doi:10.2352/J.ImagingSci.Technol.2021.65.5.050401 +// +static const float PSYCHO30_EPSILON = 1e-6f; +static const float PSYCHO30_EPSILON2 = PSYCHO30_EPSILON * PSYCHO30_EPSILON; +static const float PSYCHO30_MAX_FINITE_INPUT = 65504.f; +static const float PSYCHO30_AUTO_COMPRESSION_SENTINEL = 0.f; +static const float PSYCHO30_LARGE_SUPPORT = 1e20f; +// Kunkel/Reinhard report approximately 3.7 log10 units under their adapted +// simultaneous-range test conditions. Test30 treats half that total range as +// the range above adaptation and half as the range below adaptation. +// Jiang/Fairchild report stimulus- and display-dependent simultaneous values. +static const float PSYCHO30_REFERENCE_SIMULTANEOUS_RANGE_LOG10 = 3.7f; +static const float PSYCHO30_HIGHLIGHT_GRADE_REFERENCE_WHITE = 1.f; +static const float PSYCHO30_SHADOW_GRADE_RANGE_STOPS = 4.f; + +static const float3x3 PSYCHO30_BT709_TO_LMS_MAT = mul( + renodx::color::STOCKMAN_CVRL_XYZ_TO_LMS_2DEG_FIT, + renodx::color::BT709_TO_XYZ_MAT); +static const float3x3 PSYCHO30_LMS_TO_BT709_MAT = mul( + renodx::color::XYZ_TO_BT709_MAT, + renodx::color::STOCKMAN_CVRL_LMS_TO_XYZ_2DEG_FIT); +static const float3x3 PSYCHO30_LMS_TO_BT2020_MAT = mul( + renodx::color::XYZ_TO_BT2020_MAT, + renodx::color::STOCKMAN_CVRL_LMS_TO_XYZ_2DEG_FIT); + +static const float3 PSYCHO30_SOURCE_YF_COEFFICIENTS = mul( + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1], + PSYCHO30_BT709_TO_LMS_MAT); +static const float3 PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS = max( + PSYCHO30_SOURCE_YF_COEFFICIENTS, + float3(0.f, 0.f, 0.f)); +static const float3 PSYCHO30_SOURCE_YF_WEIGHTS = + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS + / max( + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS.x + + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS.y + + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS.z, + PSYCHO30_EPSILON); + +// BT.709 and BT.2020 share D65. These alpha values partition normalized Yf +// between the L and M cone loads and sum to one. +static const float3 PSYCHO30_D65_WHITE_LMS = mul( + PSYCHO30_BT709_TO_LMS_MAT, + float3(1.f, 1.f, 1.f)); +static const float PSYCHO30_D65_WHITE_YF = dot( + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1], + PSYCHO30_D65_WHITE_LMS); +static const float PSYCHO30_D65_ALPHA_L = + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][0] + * PSYCHO30_D65_WHITE_LMS.x + / PSYCHO30_D65_WHITE_YF; +static const float PSYCHO30_D65_ALPHA_M = + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][1] + * PSYCHO30_D65_WHITE_LMS.y + / PSYCHO30_D65_WHITE_YF; +static const float PSYCHO30_D65_ALPHA_DELTA = + PSYCHO30_D65_ALPHA_L - PSYCHO30_D65_ALPHA_M; +// Direct target basis at fixed normalized physiological coordinate A: +// +// target_rgb = A + X * A2_X_RGB + Z * A2_Z_RGB +// +// These are the symbolic inverse orthonormal-cone transform followed by the +// selected LMS-to-RGB matrix; they avoid reconstructing LMS per pixel. +static const float3 PSYCHO30_BT709_A2_X_RGB = mul( + PSYCHO30_LMS_TO_BT709_MAT, + float3( + sqrt(2.f) * PSYCHO30_D65_ALPHA_M + * PSYCHO30_D65_WHITE_LMS.x, + -sqrt(2.f) * PSYCHO30_D65_ALPHA_L + * PSYCHO30_D65_WHITE_LMS.y, + rsqrt(2.f) + * (PSYCHO30_D65_ALPHA_M + - PSYCHO30_D65_ALPHA_L) + * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_BT709_A2_Z_RGB = mul( + PSYCHO30_LMS_TO_BT709_MAT, + float3( + 0.f, + 0.f, + sqrt(6.f) * 0.5f * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_BT2020_A2_X_RGB = mul( + PSYCHO30_LMS_TO_BT2020_MAT, + float3( + sqrt(2.f) * PSYCHO30_D65_ALPHA_M + * PSYCHO30_D65_WHITE_LMS.x, + -sqrt(2.f) * PSYCHO30_D65_ALPHA_L + * PSYCHO30_D65_WHITE_LMS.y, + rsqrt(2.f) + * (PSYCHO30_D65_ALPHA_M + - PSYCHO30_D65_ALPHA_L) + * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_BT2020_A2_Z_RGB = mul( + PSYCHO30_LMS_TO_BT2020_MAT, + float3( + 0.f, + 0.f, + sqrt(6.f) * 0.5f * PSYCHO30_D65_WHITE_LMS.z)); + +// Anchor-preserving, slope-normalized finite endpoint. In scalar form, with +// q=x/anchor, k=(anchor/peak)^h, beta=h/(1-k): +// +// F(x) = peak * [1 + (1/k - 1) * q^(-beta)]^(-1/h) +// +// Thus F(anchor)=anchor, dF/dx at the anchor is one, F(0)=0, and the positive +// asymptote is `peak`. MeanA2Response fuses the common cone power into beta. +float psycho30_FiniteEndpoint( + float x, + float anchor, + float peak, + float h) { + bool uniform_response = h == 1.f; + float anchor_power = uniform_response + ? anchor / peak + : pow(anchor / peak, h); + anchor_power = max(anchor_power, 1e-37f); + float slope_normalization = max(1.f - anchor_power, PSYCHO30_EPSILON); + float normalized_input = max(x / anchor, 0.f); + if (!(normalized_input > 0.f)) return 0.f; + + float encoded = rcp( + 1.f + + (rcp(anchor_power) - 1.f) + * pow(normalized_input, -h / slope_normalization)); + return peak + * (uniform_response + ? encoded + : pow(max(encoded, 0.f), rcp(h))); +} + +// Automatic h centers the chosen simultaneous log10 range around adaptation: +// +// h = max((reference_range / 2) / log10(peak_yf / anchor_yf), 1) +// +// Manual positive h is passed through unchanged by the public entry point. +float psycho30_AutoCompressionPower(float anchor_yf, float peak_yf) { + float above_adaptation_range = log10(peak_yf / anchor_yf); + return max( + (PSYCHO30_REFERENCE_SIMULTANEOUS_RANGE_LOG10 * 0.5f) + / above_adaptation_range, + 1.f); +} + +// Preserve positive source-total bookkeeping while retaining the source RGB +// direction as far as its first lower RGB-cube boundary. This keeps finite +// signed/wide-gamut inputs defined by one direction-preserving boundary trace. +float3 psycho30_AnchorSourcePositiveTotalToYf(float3 source_rgb) { + float source_total = dot( + max(source_rgb, float3(0.f, 0.f, 0.f)), + PSYCHO30_SOURCE_YF_WEIGHTS); + if (!(source_total > PSYCHO30_EPSILON) + || isnan(source_total) + || isinf(source_total)) { + return float3(0.f, 0.f, 0.f); + } + + [branch] + if (all(source_rgb >= float3(0.f, 0.f, 0.f))) { + return mul(PSYCHO30_BT709_TO_LMS_MAT, source_rgb); + } + + float3 residual = source_rgb - source_total; + float3 lower_fraction = renodx::math::Select( + residual < float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON), + source_total / max(-residual, float3(PSYCHO30_EPSILON, PSYCHO30_EPSILON, PSYCHO30_EPSILON)), + float3( + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT)); + float boundary_fraction = min(1.f, renodx::math::Min(lower_fraction)); + float3 bounded_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + source_total + residual * boundary_fraction); + float bounded_yf = renodx::color::yf::from::LMS(bounded_lms); + return bounded_yf > PSYCHO30_EPSILON + && !isnan(bounded_yf) + && !isinf(bounded_yf) + ? bounded_lms + * (source_total * PSYCHO30_D65_WHITE_YF / bounded_yf) + : PSYCHO30_D65_WHITE_LMS * source_total; +} + +float psycho30_GradeQuinticUnitRamp(float t) { + t = saturate(t); + return t * t * t * (t * (t * 6.f - 15.f) + 10.f); +} + +float psycho30_HighlightsScalar( + float x, + float highlights, + float adapted_anchor_yf) { + if (highlights == 1.f) return x; + + float t = 0.f; + if (x > adapted_anchor_yf) { + t = saturate( + log2(x / adapted_anchor_yf) + / log2( + PSYCHO30_HIGHLIGHT_GRADE_REFERENCE_WHITE + / adapted_anchor_yf)); + } + t = psycho30_GradeQuinticUnitRamp(t); + + float ratio = max( + x / adapted_anchor_yf, + PSYCHO30_EPSILON); + if (highlights > 1.f) { + return lerp( + x, + adapted_anchor_yf * pow(ratio, highlights), + t); + } + + float compressed = adapted_anchor_yf * pow(ratio, 2.f - highlights); + return renodx::math::DivideSafe( + x * x, + lerp(x, compressed, t), + x); +} + +float psycho30_ShadowsScalar( + float x, + float shadows, + float adapted_anchor_yf) { + if (shadows == 1.f) return x; + + float ratio = max(x / adapted_anchor_yf, 0.f); + float base_term = x * adapted_anchor_yf; + float base_scale = renodx::math::DivideSafe(base_term, ratio, 0.f); + float shadow_floor = adapted_anchor_yf + * exp2(-PSYCHO30_SHADOW_GRADE_RANGE_STOPS); + float t = x > shadow_floor + ? saturate( + log2(x / adapted_anchor_yf) + / log2(shadow_floor / adapted_anchor_yf)) + : 1.f; + t = psycho30_GradeQuinticUnitRamp(t); + + if (shadows > 1.f) { + float raised = x * (1.f + renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO30_EPSILON), shadows), 0.f)); + return x + (raised - x * (1.f + base_scale)) * t; + } + + float lowered = x * (1.f - renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO30_EPSILON), 2.f - shadows), 0.f)); + return x + (lowered - x * (1.f - base_scale)) * t; +} + +// Direct LMS interpolation toward the adapting neutral at fixed +// adaptation-relative physiological Yf. The selected purity path does not +// require MacLeod-Boynton coordinates or an S-cone weight. +float3 psycho30_ApplyAdaptiveLMSPurity( + float3 input_lms, + float3 adaptive_lms, + float purity_delta) { + if (abs(purity_delta - 1.f) <= 1e-5f) return input_lms; + + float relative_yf = max( + renodx::color::yf::from::LMS(input_lms / adaptive_lms), + 0.f); + if (!(relative_yf > 0.f)) return float3(0.f, 0.f, 0.f); + + float neutral_scale = relative_yf + / renodx::color::yf::from::LMS( + float3(1.f, 1.f, 1.f)); + return lerp( + adaptive_lms * neutral_scale, + input_lms, + purity_delta); +} + +// Signed-fallback MacLeod-Boynton coordinate helpers. The selected positive +// path does not call this block. +float2 psycho30_AdaptiveNeutralMB() { + float lm_weight_sum = + renodx::color::CIE1702_MB_CIE_WEIGHTS.x + + renodx::color::CIE1702_MB_CIE_WEIGHTS.y; + return float2( + renodx::color::CIE1702_MB_CIE_WEIGHTS.x, + renodx::color::CIE1702_MB_CIE_WEIGHTS.z) + / lm_weight_sum; +} + +float3 psycho30_LMSFromYfOpponent( + float yf, + float rg, + float bv, + float3 anchor_lms) { + float2 neutral_mb = psycho30_AdaptiveNeutralMB(); + float lm_anchor_mix = mad( + anchor_lms.x, + neutral_mb.x, + anchor_lms.y * (1.f - neutral_mb.x)); + float denominator = + (yf - (anchor_lms.x - anchor_lms.y) * rg) + / lm_anchor_mix; + float3 relative_weighted = float3( + neutral_mb.x * denominator + rg, + (1.f - neutral_mb.x) * denominator - rg, + neutral_mb.y * denominator + bv); + return relative_weighted * anchor_lms + / renodx::color::CIE1702_MB_CIE_WEIGHTS; +} + +float3 psycho30_LMSFromPhysicalYfMB( + float yf, + float2 mb, + float3 anchor_lms) { + float2 neutral_mb = psycho30_AdaptiveNeutralMB(); + float2 offset = mb - neutral_mb; + float lm_anchor_mix = mad( + anchor_lms.x, + neutral_mb.x, + anchor_lms.y * (1.f - neutral_mb.x)); + float anchor_delta = anchor_lms.x - anchor_lms.y; + float relative_denominator = renodx::math::DivideSafe( + yf, + lm_anchor_mix + anchor_delta * offset.x, + 0.f); + return psycho30_LMSFromYfOpponent( + yf, + relative_denominator * offset.x, + relative_denominator * offset.y, + anchor_lms); +} + +float3 psycho30_TargetRGBFromLMS( + float3 lms, + int target_gamut_mode) { + float3 target_rgb; + [branch] + if (target_gamut_mode == 0) { + target_rgb = mul(PSYCHO30_LMS_TO_BT709_MAT, lms); + } else { + target_rgb = mul(PSYCHO30_LMS_TO_BT2020_MAT, lms); + } + return target_rgb; +} + +float psycho30_TargetNeutralYfLimit( + float target_rgb_peak, + float3 anchor_lms, + int target_gamut_mode) { + float anchor_yf = renodx::color::yf::from::LMS(anchor_lms); + if (!(anchor_yf > PSYCHO30_EPSILON)) return 0.f; + float3 rgb_per_yf = psycho30_TargetRGBFromLMS( + anchor_lms, + target_gamut_mode) + / anchor_yf; + float max_rgb_per_yf = renodx::math::Max(rgb_per_yf); + return all(rgb_per_yf >= float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON)) + && max_rgb_per_yf > PSYCHO30_EPSILON + ? target_rgb_peak / max_rgb_per_yf + : 0.f; +} + +// Weighted MacLeod-Boynton chromaticity is retained only for the signed +// fallback's source-boundary continuation. +float2 psycho30_MBFromRelativeLMS( + float3 relative_lms, + out uint valid) { + const float3 weights = renodx::color::CIE1702_MB_CIE_WEIGHTS; + float yf = renodx::color::yf::from::LMS(relative_lms); + valid = yf > PSYCHO30_EPSILON + && !isnan(yf) + && !isinf(yf) + && !any(isnan(relative_lms)) + && !any(isinf(relative_lms)) + ? 1u + : 0u; + if (valid == 0u) { + return psycho30_AdaptiveNeutralMB(); + } + float inverse_yf = rcp(yf); + return float2( + relative_lms.x * weights.x * inverse_yf, + relative_lms.z * weights.z * inverse_yf); +} + +float3 psycho30_ApplySignedConeResponseFallback( + float3 source_relative_lms, + float response_power) { + if (abs(response_power - 1.f) <= PSYCHO30_EPSILON) { + return source_relative_lms; + } + return sign(source_relative_lms) + * pow( + abs(source_relative_lms), + float3(response_power, response_power, response_power)); +} + +// Build the selected response coordinate directly from normalized response u: +// source q authors one A2 direction, finite-G u authors the other direction +// and supplies radius, C0, and normalized physiological Yf. Equal normalized +// direction weights form the exact angular midpoint when both are defined. +float3 psycho30_MeanA2Response( + float3 input_lms, + float3 anchor_in_lms, + float3 anchor_out_lms, + float3 peak_lms, + float response_power, + float response_h, + out float response_yf, + out uint valid) { + float3 source_q = input_lms / anchor_in_lms; + valid = all(source_q > float3(0.f, 0.f, 0.f)) ? 1u : 0u; + if (valid == 0u) { + response_yf = 0.f; + return float3(0.f, 0.f, 0.f); + } + + bool uniform_response = response_h == 1.f; + float3 anchor_power; + [branch] + if (uniform_response) { + anchor_power = anchor_out_lms / peak_lms; + } else { + anchor_power = pow( + anchor_out_lms / peak_lms, + float3(response_h, response_h, response_h)); + } + anchor_power = max( + anchor_power, + float3(1e-37f, 1e-37f, 1e-37f)); + float3 slope_normalization = max( + float3(1.f, 1.f, 1.f) - anchor_power, + float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON)); + float3 input_exponent = response_power * response_h / slope_normalization; + float3 encoded = rcp( + float3(1.f, 1.f, 1.f) + + (rcp(anchor_power) - float3(1.f, 1.f, 1.f)) + * pow(source_q, -input_exponent)); + float3 response_u; + [branch] + if (uniform_response) { + response_u = encoded; + } else { + float inverse_response_h = rcp(response_h); + response_u = pow( + max(encoded, float3(0.f, 0.f, 0.f)), + float3( + inverse_response_h, + inverse_response_h, + inverse_response_h)); + } + float2 source_a2 = float2( + (source_q.x - source_q.y) * rsqrt(2.f), + (2.f * source_q.z - source_q.x - source_q.y) + * rsqrt(6.f)); + float2 response_a2 = float2( + (response_u.x - response_u.y) * rsqrt(2.f), + (2.f * response_u.z - response_u.x - response_u.y) + * rsqrt(6.f)); + float2 authored_a2 = response_a2; + float source_radius2 = dot(source_a2, source_a2); + float response_radius2 = dot(response_a2, response_a2); + + if (source_radius2 > PSYCHO30_EPSILON2 + && response_radius2 > PSYCHO30_EPSILON2) { + float inverse_source_radius = rsqrt(source_radius2); + float inverse_response_radius = rsqrt(response_radius2); + float response_radius = response_radius2 * inverse_response_radius; + float2 mean_direction = source_a2 * inverse_source_radius + + response_a2 * inverse_response_radius; + float mean_radius2 = dot(mean_direction, mean_direction); + if (mean_radius2 > PSYCHO30_EPSILON2) { + authored_a2 = mean_direction + * rsqrt(mean_radius2) + * response_radius; + } + } + + response_yf = PSYCHO30_D65_ALPHA_L * response_u.x + + PSYCHO30_D65_ALPHA_M * response_u.y; + float3 desired_ortho = float3( + authored_a2.x, + (response_u.x + response_u.y + response_u.z) * rsqrt(3.f), + authored_a2.y); + return desired_ortho; +} + +float2 psycho30_ClosestPointOnScaleSegment( + float desired_c0, + float desired_rho2, + float2 segment_start, + float2 segment_end) { + float2 segment = segment_end - segment_start; + float denominator = segment.x * segment.x + + desired_rho2 * segment.y * segment.y; + if (!(denominator > PSYCHO30_EPSILON2)) return segment_start; + float numerator = (desired_c0 - segment_start.x) * segment.x + + desired_rho2 * (1.f - segment_start.y) * segment.y; + float t = saturate(numerator / denominator); + return segment_start + segment * t; +} + +// Exact nearest point for the fixed authored A2 direction. The RGB cube and +// A<=response_yf ceiling become a four-edge convex polygon in (C0, radial +// scale). `desired_rho2` in the segment metric preserves ordinary Euclidean +// distance in (X,C0,Z). +// For radial target RGB r, n=max(-r), and p=max(r), feasibility is exactly: +// +// scale * n <= A <= 1 - scale * p +// 0 <= A <= min(response_yf, 1) +float3 psycho30_YfCeilingSolve( + float3 desired_coord, + float response_yf, + int target_gamut_mode, + out uint valid) { + valid = !any(isnan(desired_coord)) + && !any(isinf(desired_coord)) + && !isnan(response_yf) + && !isinf(response_yf) + ? 1u + : 0u; + if (valid == 0u) return float3(0.f, 0.f, 0.f); + + float max_a = saturate(response_yf); + float radial_yf = PSYCHO30_D65_ALPHA_DELTA + * desired_coord.x * rsqrt(2.f) + - desired_coord.z * rsqrt(6.f); + float desired_a = desired_coord.y * rsqrt(3.f) + radial_yf; + float3 radial_rgb; + [branch] + if (target_gamut_mode == 0) { + radial_rgb = desired_coord.x * PSYCHO30_BT709_A2_X_RGB + + desired_coord.z * PSYCHO30_BT709_A2_Z_RGB; + } else { + radial_rgb = desired_coord.x * PSYCHO30_BT2020_A2_X_RGB + + desired_coord.z * PSYCHO30_BT2020_A2_Z_RGB; + } + float3 desired_target_rgb = desired_a + radial_rgb; + if (desired_a >= 0.f + && desired_a <= max_a + && all(desired_target_rgb >= float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON)) + && all(desired_target_rgb <= float3( + 1.f + PSYCHO30_EPSILON, + 1.f + PSYCHO30_EPSILON, + 1.f + PSYCHO30_EPSILON))) { + return desired_coord; + } + + float desired_rho2 = dot(desired_coord.xz, desired_coord.xz); + if (!(desired_rho2 > PSYCHO30_EPSILON2)) { + return float3( + 0.f, + clamp(desired_coord.y, 0.f, sqrt(3.f) * max_a), + 0.f); + } + + float positive_pressure = renodx::math::Max(radial_rgb); + float negative_pressure = -renodx::math::Min(radial_rgb); + if (!(positive_pressure > 0.f) + || !(negative_pressure > 0.f)) { + valid = 0u; + return float3(0.f, 0.f, 0.f); + } + + float inverse_positive = rcp(positive_pressure); + float inverse_negative = rcp(negative_pressure); + float inverse_pressure_sum = rcp( + positive_pressure + negative_pressure); + float apex_a = negative_pressure * inverse_pressure_sum; + float upper_a = min(max_a, apex_a); + float max_a_scale = min( + max_a * inverse_negative, + (1.f - max_a) * inverse_positive); + float upper_scale = min( + max_a * inverse_negative, + inverse_pressure_sum); + float2 vertex0 = float2(0.f, 0.f); + float2 vertex1 = float2(sqrt(3.f) * max_a, 0.f); + float2 vertex2 = float2( + sqrt(3.f) * (max_a - radial_yf * max_a_scale), + max_a_scale); + float2 vertex3 = float2( + sqrt(3.f) * (upper_a - radial_yf * upper_scale), + upper_scale); + float2 best_c0_scale = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex0, + vertex1); + float2 best_delta = best_c0_scale - float2(desired_coord.y, 1.f); + float best_cost = best_delta.x * best_delta.x + + desired_rho2 * best_delta.y * best_delta.y; + + float2 candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex1, + vertex2); + float2 candidate_delta = candidate - float2(desired_coord.y, 1.f); + float candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + best_cost = candidate_cost; + } + + candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex2, + vertex3); + candidate_delta = candidate - float2(desired_coord.y, 1.f); + candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + best_cost = candidate_cost; + } + + candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex0, + vertex3); + candidate_delta = candidate - float2(desired_coord.y, 1.f); + candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + } + + float3 solved_coord = float3( + desired_coord.x * max(best_c0_scale.y, 0.f), + best_c0_scale.x, + desired_coord.z * max(best_c0_scale.y, 0.f)); + valid = !any(isnan(solved_coord)) && !any(isinf(solved_coord)) ? 1u : 0u; + return valid != 0u ? solved_coord : float3(0.f, 0.f, 0.f); +} + +float2 psycho30_LinearA2Opponent( + float3 lms, + float3 anchor_lms) { + float3 q = lms / anchor_lms; + return float2( + (q.x - q.y) * rsqrt(2.f), + (2.f * q.z - q.x - q.y) * rsqrt(6.f)); +} + +float3 psycho30_LMSFromLinearA2Opponent( + float2 opponent, + float physical_yf, + float3 anchor_lms) { + float difference = sqrt(2.f) * opponent.x; + float a_l = renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][0] + * anchor_lms.x; + float a_m = renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][1] + * anchor_lms.y; + float q_m = (physical_yf - a_l * difference) / (a_l + a_m); + float q_l = q_m + difference; + float q_s = 0.5f * (sqrt(6.f) * opponent.y + q_l + q_m); + return float3(q_l, q_m, q_s) * anchor_lms; +} + +float psycho30_LinearA2TargetSupport( + float2 direction, + float clip_magnitude, + float physical_yf, + float3 anchor_lms, + int target_gamut_mode, + float target_rgb_peak) { + if (!(clip_magnitude > PSYCHO30_EPSILON)) return 0.f; + + float3 neutral_target = psycho30_TargetRGBFromLMS( + psycho30_LMSFromLinearA2Opponent( + float2(0.f, 0.f), + physical_yf, + anchor_lms), + target_gamut_mode); + if (any(isnan(neutral_target)) + || any(isinf(neutral_target)) + || any(neutral_target < float3(0.f, 0.f, 0.f)) + || any(neutral_target > float3( + target_rgb_peak, + target_rgb_peak, + target_rgb_peak))) { + return 0.f; + } + + float3 unit_target = psycho30_TargetRGBFromLMS( + psycho30_LMSFromLinearA2Opponent( + direction, + physical_yf, + anchor_lms), + target_gamut_mode); + float3 delta_target = unit_target - neutral_target; + if (any(isnan(delta_target)) || any(isinf(delta_target))) return 0.f; + + float3 upper_support = renodx::math::Select( + delta_target > float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON), + (float3(target_rgb_peak, target_rgb_peak, target_rgb_peak) - neutral_target) + / max( + delta_target, + float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON)), + float3( + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT)); + float3 lower_support = renodx::math::Select( + delta_target < float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON), + neutral_target + / max( + -delta_target, + float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON)), + float3( + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT)); + return max( + min( + clip_magnitude, + min( + renodx::math::Min(upper_support), + renodx::math::Min(lower_support))), + 0.f); +} + +float psycho30_Cross2(float2 a, float2 b) { + return a.x * b.y - a.y * b.x; +} + +float psycho30_RaySegmentRadius( + float2 origin, + float2 direction, + float2 a, + float2 b) { + float2 edge = b - a; + float denominator = psycho30_Cross2(direction, edge); + if (abs(denominator) <= PSYCHO30_EPSILON) return PSYCHO30_LARGE_SUPPORT; + float2 ao = a - origin; + float t = psycho30_Cross2(ao, edge) / denominator; + float u = psycho30_Cross2(ao, direction) / denominator; + return t >= 0.f && u >= 0.f && u <= 1.f + ? t + : PSYCHO30_LARGE_SUPPORT; +} + +float psycho30_TransformedSourceClipLinearA2Magnitude( + float2 source_mb, + float response_power, + float3 response_anchor_ratio, + float physical_yf, + float3 anchor_lms) { + float2 neutral_mb = psycho30_AdaptiveNeutralMB(); + float2 source_offset = source_mb - neutral_mb; + float source_radius2 = dot(source_offset, source_offset); + if (!(source_radius2 > PSYCHO30_EPSILON2)) return 0.f; + + float2 vertices[3]; + [unroll] + for (int channel = 0; channel < 3; ++channel) { + float3 primary_lms = float3( + PSYCHO30_BT709_TO_LMS_MAT[0][channel], + PSYCHO30_BT709_TO_LMS_MAT[1][channel], + PSYCHO30_BT709_TO_LMS_MAT[2][channel]); + uint primary_valid; + vertices[channel] = psycho30_MBFromRelativeLMS( + primary_lms / anchor_lms, + primary_valid); + } + float2 source_direction = source_offset * rsqrt(source_radius2); + float source_boundary_radius = min( + psycho30_RaySegmentRadius( + neutral_mb, + source_direction, + vertices[0], + vertices[1]), + min( + psycho30_RaySegmentRadius( + neutral_mb, + source_direction, + vertices[1], + vertices[2]), + psycho30_RaySegmentRadius( + neutral_mb, + source_direction, + vertices[2], + vertices[0]))); + if (!(source_boundary_radius < PSYCHO30_LARGE_SUPPORT)) return 0.f; + + float2 boundary_mb = neutral_mb + + source_direction * max(source_boundary_radius, 0.f); + const float3 weights = renodx::color::CIE1702_MB_CIE_WEIGHTS; + float m_fraction = 1.f - boundary_mb.x; + if (!(boundary_mb.x > PSYCHO30_EPSILON) + || !(m_fraction > PSYCHO30_EPSILON) + || !(boundary_mb.y > PSYCHO30_EPSILON)) { + return 0.f; + } + float inverse_m_fraction = rcp(m_fraction); + float2 response_ratio = exp2( + log2(max( + float2( + boundary_mb.x * weights.y * inverse_m_fraction / weights.x, + boundary_mb.y * weights.y * inverse_m_fraction / weights.z), + float2(PSYCHO30_EPSILON, PSYCHO30_EPSILON))) + * response_power); + response_ratio *= float2( + response_anchor_ratio.x / response_anchor_ratio.y, + response_anchor_ratio.z / response_anchor_ratio.y); + float lm_ratio = (weights.x / weights.y) * response_ratio.x; + float sm_ratio = (weights.z / weights.y) * response_ratio.y; + float inverse_denominator = rcp(1.f + lm_ratio); + float2 response_boundary_mb = float2( + lm_ratio * inverse_denominator, + sm_ratio * inverse_denominator); + float3 boundary_lms = psycho30_LMSFromPhysicalYfMB( + physical_yf, + response_boundary_mb, + anchor_lms); + return length(psycho30_LinearA2Opponent(boundary_lms, anchor_lms)); +} + +float psycho30_NeutwoWithClip( + float x, + float peak, + float clip, + float h) { + x = max(x, 0.f); + peak = max(peak, 0.f); + if (!(peak > PSYCHO30_EPSILON)) return 0.f; + clip = max(clip, peak); + if (clip <= peak * (1.f + PSYCHO30_EPSILON)) return min(x, peak); + float q = saturate(x / clip); + float k = saturate(peak / clip); + float qh = pow(max(q, 0.f), h); + float kh = max(pow(max(k, PSYCHO30_EPSILON), h), 1e-37f); + float denominator = pow( + max(qh * (1.f - kh) + kh, 1e-37f), + rcp(h)); + return peak * q / max(denominator, PSYCHO30_EPSILON); +} + +// Defined-domain fallback for signed adaptation-relative LMS containing a zero +// or negative cone value. +// It uses sign-preserving cone power, linear A2 direction authoring, scalar Yf +// compression, weighted-MB source-boundary continuation, and analytic +// intersections with all lower and upper selected-target RGB-cube planes. +// This is an engineering continuity and full-strength target containment path. +float3 psycho30_LinearA2Fallback( + float3 input_lms, + float3 anchor_in_lms, + float3 anchor_out_lms, + int target_gamut_mode, + float target_rgb_peak, + float response_power, + float response_h, + float target_compression_strength) { + float3 source_q = input_lms / anchor_in_lms; + float3 response_lms = anchor_out_lms + * psycho30_ApplySignedConeResponseFallback( + source_q, + response_power); + + float2 source_opponent = psycho30_LinearA2Opponent( + input_lms, + anchor_in_lms); + float2 response_opponent = psycho30_LinearA2Opponent( + response_lms, + anchor_in_lms); + float source_radius2 = dot(source_opponent, source_opponent); + float response_radius2 = dot(response_opponent, response_opponent); + float3 authored_lms = response_lms; + if (source_radius2 > PSYCHO30_EPSILON2 + && response_radius2 > PSYCHO30_EPSILON2) { + float inverse_source_radius = rsqrt(source_radius2); + float inverse_response_radius = rsqrt(response_radius2); + float response_radius = response_radius2 * inverse_response_radius; + float2 midpoint = source_opponent * inverse_source_radius + + response_opponent * inverse_response_radius; + float midpoint_length2 = dot(midpoint, midpoint); + if (midpoint_length2 > PSYCHO30_EPSILON2) { + authored_lms = psycho30_LMSFromLinearA2Opponent( + midpoint * rsqrt(midpoint_length2) * response_radius, + max(renodx::color::yf::from::LMS(response_lms), 0.f), + anchor_in_lms); + } + } + + float neutral_yf_limit = psycho30_TargetNeutralYfLimit( + target_rgb_peak, + anchor_in_lms, + target_gamut_mode); + if (!(neutral_yf_limit > PSYCHO30_EPSILON)) { + return float3(0.f, 0.f, 0.f); + } + float anchor_out_yf = renodx::color::yf::from::LMS(anchor_out_lms); + float target_yf = psycho30_FiniteEndpoint( + max(renodx::color::yf::from::LMS(response_lms), 0.f), + anchor_out_yf, + neutral_yf_limit, + response_h); + + uint authored_mb_valid; + float2 authored_mb = psycho30_MBFromRelativeLMS( + authored_lms / anchor_in_lms, + authored_mb_valid); + if (authored_mb_valid == 0u) { + return psycho30_LMSFromYfOpponent( + target_yf, + 0.f, + 0.f, + anchor_in_lms); + } + + float3 desired_lms = psycho30_LMSFromPhysicalYfMB( + target_yf, + authored_mb, + anchor_in_lms); + float2 desired_opponent = psycho30_LinearA2Opponent( + desired_lms, + anchor_in_lms); + float desired_magnitude2 = dot(desired_opponent, desired_opponent); + if (!(desired_magnitude2 > PSYCHO30_EPSILON2)) { + return psycho30_LMSFromYfOpponent( + target_yf, + 0.f, + 0.f, + anchor_in_lms); + } + + float inverse_desired_magnitude = rsqrt(desired_magnitude2); + float desired_magnitude = desired_magnitude2 * inverse_desired_magnitude; + float2 direction = desired_opponent * inverse_desired_magnitude; + float source_clip_magnitude = max( + psycho30_TransformedSourceClipLinearA2Magnitude( + psycho30_MBFromRelativeLMS(source_q, authored_mb_valid), + response_power, + anchor_out_lms / anchor_in_lms, + target_yf, + anchor_in_lms), + desired_magnitude); + float target_support = psycho30_LinearA2TargetSupport( + direction, + source_clip_magnitude, + target_yf, + anchor_in_lms, + target_gamut_mode, + target_rgb_peak); + float compressed_magnitude = min( + psycho30_NeutwoWithClip( + desired_magnitude, + target_support, + max(source_clip_magnitude, target_support), + response_h), + target_support); + return psycho30_LMSFromLinearA2Opponent( + direction * lerp(desired_magnitude, compressed_magnitude, target_compression_strength), + target_yf, + anchor_in_lms); +} + +float3 psychotm_test30( + // Direct linear-light BT.709 RGB. + // Configuration values are trusted; only the input color is sanitized. + float3 bt709_linear_input, + float peak_value = 1000.f / 203.f, // display peak / reference white + float exposure = 1.f, // linear-light multiplier + float highlights = 1.f, // scalar-Yf highlight grade + float shadows = 1.f, // scalar-Yf shadow grade + float contrast = 1.f, // factor in common cone power p + float purity_scale = 1.f, // adaptation-relative LMS purity + float bleaching_intensity = 1.f, // positional compatibility placeholder + float clip_point = 100.f, // positional compatibility placeholder + float hue_restore = 1.f, // positional compatibility placeholder + float encoded_response_power = 1.f, // positional compatibility placeholder + int white_curve_mode = 0, // positional compatibility placeholder + float cone_response_exponent = 1.f, // second factor in cone power p + float3 current_adaptive_state_bt709 = 0.18f, // input anchor + float3 current_background_state_bt709 = 0.18f, // output anchor + float gamut_compression = 1.f, // target-projection strength + int gamut_compression_mode = 1, // 0 = BT.709, nonzero = BT.2020 + float adaptive_normalization = 1.f, // positional compatibility placeholder + float compression = 0.f) { // positive manual h; 0 = auto + // ------------------------------------------------------------------------- + // Source signal and signed-domain policy. + // ------------------------------------------------------------------------- + float3 sanitized_input = renodx::math::ZeroNaN(bt709_linear_input); + sanitized_input = renodx::math::Select( + isinf(sanitized_input), + renodx::math::CopySign( + float3( + PSYCHO30_MAX_FINITE_INPUT, + PSYCHO30_MAX_FINITE_INPUT, + PSYCHO30_MAX_FINITE_INPUT), + sanitized_input), + sanitized_input); + float3 exposed_input = sanitized_input * exposure; + + float3 anchored_lms = psycho30_AnchorSourcePositiveTotalToYf(exposed_input); + if (all(anchored_lms == float3(0.f, 0.f, 0.f))) { + return float3(0.f, 0.f, 0.f); + } + + float3 anchor_in_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + current_adaptive_state_bt709); + float3 anchor_out_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + current_background_state_bt709); + + // ------------------------------------------------------------------------- + // Observer-basis controls: scalar physiological-Yf grading followed by + // adaptation-relative LMS purity. These precede the finite cone response. + // ------------------------------------------------------------------------- + float3 graded_lms = anchored_lms; + [branch] + if (highlights != 1.f || shadows != 1.f) { + graded_lms = abs(anchored_lms); + float graded_yf = max( + renodx::color::yf::from::LMS(graded_lms), + PSYCHO30_EPSILON); + float adapted_anchor_yf = renodx::color::yf::from::LMS(anchor_in_lms); + float graded_yf_out = psycho30_HighlightsScalar( + graded_yf, + highlights, + adapted_anchor_yf); + graded_yf_out = psycho30_ShadowsScalar( + graded_yf_out, + shadows, + adapted_anchor_yf); + graded_lms *= renodx::math::DivideSafe( + graded_yf_out, + graded_yf, + 1.f); + graded_lms = renodx::math::CopySign(graded_lms, anchored_lms); + } + + float response_scale = cone_response_exponent; + float response_power = contrast * response_scale; + float purity_delta = renodx::math::DivideSafe( + purity_scale, + contrast, + 1.f); + float3 response_input_lms = psycho30_ApplyAdaptiveLMSPurity( + graded_lms, + anchor_in_lms, + purity_delta); + + // ------------------------------------------------------------------------- + // Positive finite-G response and Mean-A2 direction authoring. + // ------------------------------------------------------------------------- + float target_rgb_peak = peak_value; + float3 target_peak_lms = PSYCHO30_D65_WHITE_LMS * target_rgb_peak; + float response_h = compression; + [branch] + if (compression == PSYCHO30_AUTO_COMPRESSION_SENTINEL) { + response_h = psycho30_AutoCompressionPower( + renodx::color::yf::from::LMS(anchor_out_lms), + psycho30_TargetNeutralYfLimit( + target_rgb_peak, + anchor_in_lms, + gamut_compression_mode)); + } + + float response_yf; + uint response_valid; + float3 desired_coord = psycho30_MeanA2Response( + response_input_lms, + anchor_in_lms, + anchor_out_lms, + target_peak_lms, + response_power, + response_h, + response_yf, + response_valid); + [branch] + if (response_valid == 0u) { + // Signed cone states use the separate defined-domain path. + float3 fallback_lms = psycho30_LinearA2Fallback( + response_input_lms, + anchor_in_lms, + anchor_out_lms, + gamut_compression_mode, + target_rgb_peak, + response_power, + response_h, + gamut_compression); + float3 fallback_bt709 = mul( + PSYCHO30_LMS_TO_BT709_MAT, + fallback_lms); + return !any(isnan(fallback_bt709)) && !any(isinf(fallback_bt709)) + ? fallback_bt709 + : float3(0.f, 0.f, 0.f); + } + + // ------------------------------------------------------------------------- + // Device mapping: exact fixed-direction projection into the selected + // normalized RGB cube with the post-response physiological-Yf ceiling. + // ------------------------------------------------------------------------- + float target_compression_weight = gamut_compression; + float3 selected_coord = desired_coord; + if (target_compression_weight != 0.f) { + uint solve_valid; + float3 solved_coord = psycho30_YfCeilingSolve( + desired_coord, + response_yf, + gamut_compression_mode, + solve_valid); + if (solve_valid == 0u) return float3(0.f, 0.f, 0.f); + selected_coord = target_compression_weight == 1.f + ? solved_coord + : lerp( + desired_coord, + solved_coord, + target_compression_weight); + } + + // Direct inverse A2/Yf basis to linear BT.709. This is algebraically the + // normalized cone-coordinate inverse plus LMS-to-BT.709 matrix product. + float output_a = selected_coord.y * rsqrt(3.f) + + PSYCHO30_D65_ALPHA_DELTA + * selected_coord.x * rsqrt(2.f) + - selected_coord.z * rsqrt(6.f); + float3 output_bt709 = peak_value + * (output_a + + selected_coord.x * PSYCHO30_BT709_A2_X_RGB + + selected_coord.z * PSYCHO30_BT709_A2_Z_RGB); + return !any(isnan(output_bt709)) && !any(isinf(output_bt709)) + ? output_bt709 + : float3(0.f, 0.f, 0.f); +} + +static const int PSYCHO30_TARGET_GAMUT_BT709 = 0; +static const int PSYCHO30_TARGET_GAMUT_BT2020 = 1; +static const int PSYCHO30_TARGET_GAMUT_DISPLAY_P3 = 3; +static const int PSYCHO30_CUSTOM_GAMUT_MAPPING_EXACT_PROJECTION = 0; +static const int PSYCHO30_CUSTOM_GAMUT_MAPPING_SOFT_RADIAL = 1; +static const float PSYCHO30_CUSTOM_GAMUT_COMPRESSION_KNEE = 0.9f; +// (0, 1] guarantees monotonic containment; 1 is the firmest valid response. +static const float PSYCHO30_CUSTOM_GAMUT_COMPRESSION_FIRMNESS = 0.65f; +static const float PSYCHO30_CUSTOM_GAMUT_COMPRESSION_EXP2_SCALE = PSYCHO30_CUSTOM_GAMUT_COMPRESSION_FIRMNESS / log(2.f); + +static const float3x3 PSYCHO30_LMS_TO_DISPLAY_P3_MAT = mul( + renodx::color::XYZ_TO_DISPLAYP3_MAT, + renodx::color::STOCKMAN_CVRL_LMS_TO_XYZ_2DEG_FIT); +static const float3 PSYCHO30_DISPLAY_P3_A2_X_RGB = mul( + PSYCHO30_LMS_TO_DISPLAY_P3_MAT, + float3( + sqrt(2.f) * PSYCHO30_D65_ALPHA_M + * PSYCHO30_D65_WHITE_LMS.x, + -sqrt(2.f) * PSYCHO30_D65_ALPHA_L + * PSYCHO30_D65_WHITE_LMS.y, + rsqrt(2.f) + * (PSYCHO30_D65_ALPHA_M + - PSYCHO30_D65_ALPHA_L) + * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_DISPLAY_P3_A2_Z_RGB = mul( + PSYCHO30_LMS_TO_DISPLAY_P3_MAT, + float3(0.f, 0.f, sqrt(6.f) * 0.5f * PSYCHO30_D65_WHITE_LMS.z)); + +// Streamlined Test30 variant. Anchored tonal grading replaces the original +// scalar-Yf highlights/shadows, common cone power, and finite-G response. Its +// output anchor is also the exact C-infinity shoulder anchor. The resulting +// per-cone response still supplies Test30's Mean-A2 direction/radius and exact +// fixed-direction target-cube projection. +float3 psycho30_CustomCInfinityTransition(float3 position) { + position = saturate(position); + return rcp(1.f + exp2((1.f - 2.f * position) / (position * (1.f - position)))); +} + +float3 psycho30_ApplyAnchoredTonalGrading( + float3 color, + float3 anchor_in, float3 anchor_out, + float contrast, float flare, + float highlight_contrast, float shadow_contrast, + float highlights, float shadows) { + [branch] + if (contrast == 1.f && flare == 0.f + && highlight_contrast == 1.f && shadow_contrast == 1.f + && highlights == 1.f && shadows == 1.f + && all(anchor_in == anchor_out)) { + return color; + } + + float3 normalized = color / anchor_in; + float3 graded_normalized = normalized; + + // Power contrast below the anchor and bounded log-domain contrast above it. + // Flare increases only the deep-shadow exponent. + [branch] + if (contrast != 1.f || flare > 0.f) { + float3 exponent = contrast; + + [branch] + if (flare > 0.f) { + float3 shadow_distance = saturate(1.f - normalized); + float3 flat_shadow_weight = exp2(-normalized / shadow_distance); + exponent *= mad(flat_shadow_weight, flare / (normalized + flare), 1.f); + } + + float3 input_stops = log2(normalized); + float3 highlight_stops = max(input_stops, 0.f); + float3 output_highlight_stops = highlight_stops; + + [branch] + if (contrast != 1.f) { + float3 displacement = (contrast - 1.f) * highlight_stops; + float3 displacement_magnitude = abs(displacement); + output_highlight_stops += displacement / mad(displacement_magnitude, exp2(-1.f / displacement_magnitude), 1.f); + } + + graded_normalized = exp2(mad(exponent, min(input_stops, 0.f), output_highlight_stops)); + } + + [branch] + if (highlight_contrast != 1.f) { + float3 distance = max(graded_normalized - 1.f, 0.f); + float3 distance_squared = distance * distance; + float3 flat_distance = (1.f + distance_squared) * exp2(-1.f / distance_squared); + graded_normalized += distance * (pow(1.f + flat_distance, 0.5f * (highlight_contrast - 1.f)) - 1.f); + } + + [branch] + if (shadow_contrast != 1.f) { + float3 distance = saturate(1.f - graded_normalized); + float3 distance_squared = distance * distance; + float3 flat_distance = distance_squared * distance * exp2(1.f - 1.f / distance_squared); + graded_normalized *= pow(1.f + flat_distance, shadow_contrast - 1.f); + } + + [branch] + if (highlights != 1.f || shadows != 1.f) { + static const float TONAL_OFFSET_START_STOPS = 1.f; + static const float TONAL_OFFSET_END_STOPS = 8.f; + static const float TONAL_OFFSET_INVERSE_RANGE_STOPS = 1.f / (TONAL_OFFSET_END_STOPS - TONAL_OFFSET_START_STOPS); + + float3 tonal_stops = log2(graded_normalized); + float3 tonal_displacement = 0.f; + + [branch] + if (highlights != 1.f) { + float adjustment = highlights - 1.f; + float displacement = adjustment * mad(1.5f, abs(adjustment), 0.5f); + float3 weight = psycho30_CustomCInfinityTransition( + (tonal_stops - TONAL_OFFSET_START_STOPS) + * TONAL_OFFSET_INVERSE_RANGE_STOPS); + tonal_displacement = mad(displacement, weight, tonal_displacement); + } + + [branch] + if (shadows != 1.f) { + float adjustment = shadows - 1.f; + float displacement = adjustment * mad(1.5f, abs(adjustment), 0.5f); + float3 weight = psycho30_CustomCInfinityTransition( + (-TONAL_OFFSET_START_STOPS - tonal_stops) + * TONAL_OFFSET_INVERSE_RANGE_STOPS); + tonal_displacement = mad(displacement, weight, tonal_displacement); + } + + graded_normalized *= exp2(tonal_displacement); + } + + return graded_normalized * anchor_out; +} + +float3 psycho30_ApplyAnchoredCInfinityShoulder( + float3 color, + float3 peak, + float3 anchor, + float compression_strength) { + float3 shoulder_range = peak - anchor; + float3 distance_from_anchor = max(color - anchor, 0.f); + float3 flat_weight = exp2(-shoulder_range / (compression_strength * distance_from_anchor)); + float3 response_denominator = mad(distance_from_anchor, flat_weight, shoulder_range); + return mad(shoulder_range, distance_from_anchor / response_denominator, color - distance_from_anchor); +} + +// Construct Test30's orthonormal response coordinate from the precomputed +// nonnegative per-cone response. A source weight of 0 selects the response +// direction; 1 reproduces Test30's exact source/response angular midpoint. +float3 psycho30_MeanA2ResponseFromCustomResponse( + float3 source_q, + float3 response_u, + float source_direction_weight, + out float response_yf, + out uint valid) { + valid = all(source_q >= float3(0.f, 0.f, 0.f)) + && all(response_u >= float3(0.f, 0.f, 0.f)) + && !any(isnan(source_q)) + && !any(isinf(source_q)) + && !any(isnan(response_u)) + && !any(isinf(response_u)) + ? 1u + : 0u; + if (valid == 0u) { + response_yf = 0.f; + return float3(0.f, 0.f, 0.f); + } + + float2 source_a2 = float2( + (source_q.x - source_q.y) * rsqrt(2.f), + (2.f * source_q.z - source_q.x - source_q.y) * rsqrt(6.f)); + float2 response_a2 = float2( + (response_u.x - response_u.y) * rsqrt(2.f), + (2.f * response_u.z - response_u.x - response_u.y) * rsqrt(6.f)); + float2 authored_a2 = response_a2; + float source_radius2 = dot(source_a2, source_a2); + float response_radius2 = dot(response_a2, response_a2); + + if (source_radius2 > PSYCHO30_EPSILON2 + && response_radius2 > PSYCHO30_EPSILON2) { + float inverse_response_radius = rsqrt(response_radius2); + float response_radius = response_radius2 * inverse_response_radius; + float2 mean_direction = mad( + source_a2, + rsqrt(source_radius2) * source_direction_weight, + response_a2 * inverse_response_radius); + float mean_radius2 = dot(mean_direction, mean_direction); + if (mean_radius2 > PSYCHO30_EPSILON2) { + authored_a2 = mean_direction + * rsqrt(mean_radius2) + * response_radius; + } + } + + response_yf = PSYCHO30_D65_ALPHA_L * response_u.x + + PSYCHO30_D65_ALPHA_M * response_u.y; + return float3( + authored_a2.x, + (response_u.x + response_u.y + response_u.z) * rsqrt(3.f), + authored_a2.y); +} + +// Preserve the custom path's authored A2 direction and desired physiological +// A while smoothly reducing radius against all six target RGB-cube planes. +// The response Yf remains an upper A ceiling. Working in scale space avoids +// direction normalization and keeps the common below-knee path division-free. +float3 psycho30_ApplyCustomSoftRadialGamutCompression( + float3 desired_coord, + float response_yf, + int target_gamut_mode, + out uint valid) { + valid = !any(isnan(desired_coord)) + && !any(isinf(desired_coord)) + && !isnan(response_yf) + && !isinf(response_yf) + ? 1u + : 0u; + if (valid == 0u) return float3(0.f, 0.f, 0.f); + + float radial_yf = PSYCHO30_D65_ALPHA_DELTA + * desired_coord.x * rsqrt(2.f) + - desired_coord.z * rsqrt(6.f); + float desired_a = desired_coord.y * rsqrt(3.f) + radial_yf; + float mapped_a = clamp(desired_a, 0.f, saturate(response_yf)); + float3 radial_rgb; + [branch] + if (target_gamut_mode == PSYCHO30_TARGET_GAMUT_BT709) { + radial_rgb = desired_coord.x * PSYCHO30_BT709_A2_X_RGB + + desired_coord.z * PSYCHO30_BT709_A2_Z_RGB; + } else if (target_gamut_mode == PSYCHO30_TARGET_GAMUT_DISPLAY_P3) { + radial_rgb = desired_coord.x * PSYCHO30_DISPLAY_P3_A2_X_RGB + + desired_coord.z * PSYCHO30_DISPLAY_P3_A2_Z_RGB; + } else { + radial_rgb = desired_coord.x * PSYCHO30_BT2020_A2_X_RGB + + desired_coord.z * PSYCHO30_BT2020_A2_Z_RGB; + } + + float positive_pressure = renodx::math::Max(radial_rgb); + float negative_pressure = -renodx::math::Min(radial_rgb); + if (positive_pressure + <= PSYCHO30_CUSTOM_GAMUT_COMPRESSION_KNEE * (1.f - mapped_a) + && negative_pressure + <= PSYCHO30_CUSTOM_GAMUT_COMPRESSION_KNEE * mapped_a) { + return float3( + desired_coord.x, + sqrt(3.f) * (mapped_a - radial_yf), + desired_coord.z); + } + + float support_scale = PSYCHO30_LARGE_SUPPORT; + if (positive_pressure > PSYCHO30_EPSILON) { + support_scale = min( + support_scale, + (1.f - mapped_a) / positive_pressure); + } + if (negative_pressure > PSYCHO30_EPSILON) { + support_scale = min( + support_scale, + mapped_a / negative_pressure); + } + if (!(support_scale < PSYCHO30_LARGE_SUPPORT)) { + return float3( + desired_coord.x, + sqrt(3.f) * (mapped_a - radial_yf), + desired_coord.z); + } + + support_scale = max(support_scale, 0.f); + float knee_scale = + PSYCHO30_CUSTOM_GAMUT_COMPRESSION_KNEE * support_scale; + float headroom = support_scale - knee_scale; + float excess = 1.f - knee_scale; + float headroom_per_excess = headroom * rcp(excess); + float flat_weight = exp2(-PSYCHO30_CUSTOM_GAMUT_COMPRESSION_EXP2_SCALE * headroom_per_excess); + float mapped_scale = knee_scale + headroom * rcp(headroom_per_excess + flat_weight); + mapped_scale = clamp( + mapped_scale, + 0.f, + min(1.f, support_scale)); + + float2 mapped_a2 = desired_coord.xz * mapped_scale; + float mapped_c0 = sqrt(3.f) + * (mapped_a - radial_yf * mapped_scale); + float3 mapped_coord = float3(mapped_a2.x, mapped_c0, mapped_a2.y); + valid = !any(isnan(mapped_coord)) && !any(isinf(mapped_coord)) ? 1u : 0u; + return valid != 0u ? mapped_coord : float3(0.f, 0.f, 0.f); +} + +// The original Test30 solve keeps its BT.709/BT.2020 behavior unchanged. +// This custom-only copy extends the corrected solve to Display P3. +float3 psycho30_CustomYfCeilingSolve( + float3 desired_coord, + float response_yf, + int target_gamut_mode, + out uint valid) { + if (target_gamut_mode != PSYCHO30_TARGET_GAMUT_DISPLAY_P3) { + return psycho30_YfCeilingSolve( + desired_coord, + response_yf, + target_gamut_mode, + valid); + } + + valid = !any(isnan(desired_coord)) + && !any(isinf(desired_coord)) + && !isnan(response_yf) + && !isinf(response_yf) + ? 1u + : 0u; + if (valid == 0u) return float3(0.f, 0.f, 0.f); + + float max_a = saturate(response_yf); + float radial_yf = PSYCHO30_D65_ALPHA_DELTA + * desired_coord.x * rsqrt(2.f) + - desired_coord.z * rsqrt(6.f); + float desired_a = desired_coord.y * rsqrt(3.f) + radial_yf; + float3 radial_rgb = + desired_coord.x * PSYCHO30_DISPLAY_P3_A2_X_RGB + + desired_coord.z * PSYCHO30_DISPLAY_P3_A2_Z_RGB; + float3 desired_target_rgb = desired_a + radial_rgb; + if (desired_a >= 0.f + && desired_a <= max_a + && all(desired_target_rgb >= float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON)) + && all(desired_target_rgb <= float3( + 1.f + PSYCHO30_EPSILON, + 1.f + PSYCHO30_EPSILON, + 1.f + PSYCHO30_EPSILON))) { + return desired_coord; + } + + float desired_rho2 = dot(desired_coord.xz, desired_coord.xz); + if (!(desired_rho2 > PSYCHO30_EPSILON2)) { + return float3( + 0.f, + clamp(desired_coord.y, 0.f, sqrt(3.f) * max_a), + 0.f); + } + + float positive_pressure = renodx::math::Max(radial_rgb); + float negative_pressure = -renodx::math::Min(radial_rgb); + if (!(positive_pressure > 0.f) + || !(negative_pressure > 0.f)) { + valid = 0u; + return float3(0.f, 0.f, 0.f); + } + + float inverse_positive = rcp(positive_pressure); + float inverse_negative = rcp(negative_pressure); + float inverse_pressure_sum = rcp( + positive_pressure + negative_pressure); + float apex_a = negative_pressure * inverse_pressure_sum; + float upper_a = min(max_a, apex_a); + float max_a_scale = min( + max_a * inverse_negative, + (1.f - max_a) * inverse_positive); + float upper_scale = min( + max_a * inverse_negative, + inverse_pressure_sum); + float2 vertex0 = float2(0.f, 0.f); + float2 vertex1 = float2(sqrt(3.f) * max_a, 0.f); + float2 vertex2 = float2( + sqrt(3.f) * (max_a - radial_yf * max_a_scale), + max_a_scale); + float2 vertex3 = float2( + sqrt(3.f) * (upper_a - radial_yf * upper_scale), + upper_scale); + float2 best_c0_scale = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex0, + vertex1); + float2 best_delta = best_c0_scale - float2(desired_coord.y, 1.f); + float best_cost = best_delta.x * best_delta.x + + desired_rho2 * best_delta.y * best_delta.y; + + float2 candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex1, + vertex2); + float2 candidate_delta = candidate - float2(desired_coord.y, 1.f); + float candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + best_cost = candidate_cost; + } + + candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex2, + vertex3); + candidate_delta = candidate - float2(desired_coord.y, 1.f); + candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + best_cost = candidate_cost; + } + + candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex0, + vertex3); + candidate_delta = candidate - float2(desired_coord.y, 1.f); + candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + } + + float3 solved_coord = float3( + desired_coord.x * max(best_c0_scale.y, 0.f), + best_c0_scale.x, + desired_coord.z * max(best_c0_scale.y, 0.f)); + valid = !any(isnan(solved_coord)) && !any(isinf(solved_coord)) ? 1u : 0u; + return valid != 0u ? solved_coord : float3(0.f, 0.f, 0.f); +} + +float3 psychotm_custom_test30( + float3 bt709_linear_input, + float peak_value = 1000.f / 203.f, + float exposure = 1.f, + float highlights = 1.f, + float shadows = 1.f, + float contrast = 1.f, + float flare = 0.f, + float highlight_contrast = 1.f, + float shadow_contrast = 1.f, + float purity_scale = 1.f, + float highlight_saturation = 1.f, + float dechroma = 0.f, + float3 current_adaptive_state_bt709 = 0.18f, + float3 current_background_state_bt709 = 0.18f, + float gamut_compression = 1.f, + int gamut_compression_mode = PSYCHO30_TARGET_GAMUT_BT2020, + float compression = 1.5f, + float mean_a2_source_weight = 1.f, + int gamut_mapping_method = PSYCHO30_CUSTOM_GAMUT_MAPPING_SOFT_RADIAL) { + // Use the corrected Test30 input sanitization and source-boundary policy. + float3 sanitized_input = renodx::math::ZeroNaN(bt709_linear_input); + sanitized_input = renodx::math::Select( + isinf(sanitized_input), + renodx::math::CopySign( + float3( + PSYCHO30_MAX_FINITE_INPUT, + PSYCHO30_MAX_FINITE_INPUT, + PSYCHO30_MAX_FINITE_INPUT), + sanitized_input), + sanitized_input); + float3 exposed_input = sanitized_input * exposure; + + float3 source_lms = psycho30_AnchorSourcePositiveTotalToYf(exposed_input); + if (all(source_lms == float3(0.f, 0.f, 0.f))) { + return float3(0.f, 0.f, 0.f); + } + + float3 anchor_in_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + current_adaptive_state_bt709); + float3 anchor_out_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + current_background_state_bt709); + float3 target_peak_lms = PSYCHO30_D65_WHITE_LMS * peak_value; + + float3 tonal_input_lms = source_lms; + [branch] + if (purity_scale != 1.f || highlight_saturation != 1.f || dechroma != 0.f) { + float effective_purity_scale = purity_scale; + + // Author the highlight controls in the same adaptation-relative Yf + // coordinate used by the corrected LMS purity interpolation. + [branch] + if (dechroma != 0.f || highlight_saturation != 1.f) { + static const float INVERSE_HIGHLIGHT_RANGE_STOPS = + 1.f / (2.75f * log2(10.f)); + static const float HIGHLIGHT_ROLLOFF_CUBIC_BLEND = 0.5f; + static const float HIGHLIGHT_PURITY_STRENGTH = 2.f / 3.f; + + float source_relative_yf = max( + renodx::color::yf::from::LMS(source_lms / anchor_in_lms), + 0.f); + float neutral_relative_yf = renodx::color::yf::from::LMS( + float3(1.f, 1.f, 1.f)); + float luminance_from_neutral = max(source_relative_yf, neutral_relative_yf) / neutral_relative_yf; + float rolloff_position = saturate(log2(luminance_from_neutral) * INVERSE_HIGHLIGHT_RANGE_STOPS); + float rolloff_position_squared = rolloff_position * rolloff_position; + float rolloff = rolloff_position_squared * rolloff_position * mad(rolloff_position, mad(6.f, rolloff_position, -15.f), 10.f); + + if (dechroma != 0.f) { + effective_purity_scale *= mad(-dechroma, rolloff, 1.f); + } + + if (highlight_saturation != 1.f) { + float highlight_rolloff = rolloff * rolloff + * mad( + HIGHLIGHT_ROLLOFF_CUBIC_BLEND, + rolloff, + 1.f - HIGHLIGHT_ROLLOFF_CUBIC_BLEND); + effective_purity_scale *= mad( + highlight_saturation - 1.f, + highlight_rolloff * HIGHLIGHT_PURITY_STRENGTH, + 1.f); + } + } + + tonal_input_lms = psycho30_ApplyAdaptiveLMSPurity(source_lms, anchor_in_lms, effective_purity_scale); + } + tonal_input_lms = max(tonal_input_lms, 0.f); + + // Grade the three physical LMS cone components independently after purity. + // Physiological Yf is not used by the tonal grading stage. + float3 graded_lms = psycho30_ApplyAnchoredTonalGrading( + tonal_input_lms, + anchor_in_lms, + anchor_out_lms, + contrast, + flare, + highlight_contrast, + shadow_contrast, + highlights, + shadows); + float3 response_lms = psycho30_ApplyAnchoredCInfinityShoulder( + graded_lms, + target_peak_lms, + anchor_out_lms, + compression); + + float3 source_q = tonal_input_lms / anchor_in_lms; + float3 response_u = response_lms / target_peak_lms; + float response_yf; + uint response_valid; + float3 desired_coord = psycho30_MeanA2ResponseFromCustomResponse( + source_q, + response_u, + mean_a2_source_weight, + response_yf, + response_valid); + if (response_valid == 0u) return float3(0.f, 0.f, 0.f); + + float3 selected_coord = desired_coord; + if (gamut_compression != 0.f) { + uint solve_valid; + float3 solved_coord; + [branch] + if (gamut_mapping_method == PSYCHO30_CUSTOM_GAMUT_MAPPING_EXACT_PROJECTION) { + solved_coord = psycho30_CustomYfCeilingSolve( + desired_coord, + response_yf, + gamut_compression_mode, + solve_valid); + } else { + solved_coord = psycho30_ApplyCustomSoftRadialGamutCompression( + desired_coord, + response_yf, + gamut_compression_mode, + solve_valid); + } + if (solve_valid == 0u) return float3(0.f, 0.f, 0.f); + selected_coord = gamut_compression == 1.f + ? solved_coord + : lerp( + desired_coord, + solved_coord, + gamut_compression); + } + + float output_a = selected_coord.y * rsqrt(3.f) + PSYCHO30_D65_ALPHA_DELTA * selected_coord.x * rsqrt(2.f) - selected_coord.z * rsqrt(6.f); + float3 output_bt709 = peak_value * (output_a + selected_coord.x * PSYCHO30_BT709_A2_X_RGB + selected_coord.z * PSYCHO30_BT709_A2_Z_RGB); + return !any(isnan(output_bt709)) && !any(isinf(output_bt709)) + ? output_bt709 + : float3(0.f, 0.f, 0.f); +} + +} // namespace psychov +} // namespace tonemap +} // namespace renodx + +#endif // PSYCHOV_CUSTOMTEST30_HLSLI_ \ No newline at end of file diff --git a/src/games/elitedangerous/tonemap/psychov25/customtest25.hlsli b/src/games/elitedangerous/tonemap/psychov25/customtest25.hlsli deleted file mode 100644 index 847c53986..000000000 --- a/src/games/elitedangerous/tonemap/psychov25/customtest25.hlsli +++ /dev/null @@ -1,637 +0,0 @@ -#ifndef RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ -#define RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ - -#include "../../common.hlsli" - -/* - * Copyright (C) 2026 Carlos Lopez - * SPDX-License-Identifier: MIT - */ - -namespace renodx { -namespace tonemap { -namespace psychov { - -static const float PSYCHO25_EPSILON = 1e-6f; -static const float PSYCHO25_LARGE = 1e20f; -static const float PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE = 0.9f; -static const float PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION = 0.75f; -static const float PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON = 1e-5f; -static const float PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER = 256.f; -static const float PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY = 0.8f; -static const float PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION = 0.25f; -static const float PSYCHO25_SOURCE_DIRECTION_BLEND_POWER = 2.f; - -static const int PSYCHO25_TARGET_GAMUT_BT709 = 0; -static const int PSYCHO25_TARGET_GAMUT_BT2020 = 1; -static const int PSYCHO25_TARGET_GAMUT_DISPLAY_P3 = 3; - -static const float3x3 PSYCHO25_LMS_WEIGHTED_TO_DISPLAY_P3_MAT = mul(renodx::color::XYZ_TO_DISPLAYP3_MAT, renodx::color::macleod_boynton::LMS_WEIGHTED_TO_XYZ_MAT); - -float psycho25_SignedYfFromLMS(float3 lms) { - float3 weighted_lms = renodx::color::macleod_boynton::WeighLMS(lms); - return weighted_lms.x + weighted_lms.y; -} - -float psycho25_YfFromLMS(float3 lms) { - return max(psycho25_SignedYfFromLMS(lms), PSYCHO25_EPSILON); -} - -float3 psycho25_ToAdaptiveRelativeWeightedLMS( - float3 lms_input, - float3 current_adaptive_state_lms) { - return renodx::math::DivideSafe( - renodx::color::macleod_boynton::WeighLMS(lms_input), - current_adaptive_state_lms, - 0.f.xxx); -} - -float3 psycho25_FromAdaptiveRelativeWeightedLMS( - float3 lms_weighted_relative, - float3 current_adaptive_state_lms) { - return lms_weighted_relative - * max(current_adaptive_state_lms, PSYCHO25_EPSILON.xxx); -} - -float3 psycho25_LMSFromAdaptiveMB( - float3 mb, - float3 current_adaptive_state_lms) { - float3 relative_weighted = - renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton(mb); - return renodx::color::macleod_boynton::UnweighLMS( - psycho25_FromAdaptiveRelativeWeightedLMS( - relative_weighted, - current_adaptive_state_lms)); -} - -float3 psycho25_ApplyAdaptiveMBPurity( - float3 lms_input, - float3 adaptive_neutral_lms, - float purity_delta) { - if (abs(purity_delta - 1.f) <= 1e-5f) return lms_input; - - float3 relative_weighted = psycho25_ToAdaptiveRelativeWeightedLMS( - lms_input, - adaptive_neutral_lms); - float3 mb = renodx::color::macleod_boynton::from::WeightedLMS( - relative_weighted); - float3 mb_neutral = renodx::color::macleod_boynton::from::LMS(1.f.xxx); - float2 mb_scaled_xy = lerp(mb_neutral.xy, mb.xy, purity_delta); - float3 relative_weighted_out = - renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton( - float3(mb_scaled_xy, mb.z)); - return renodx::color::macleod_boynton::UnweighLMS( - psycho25_FromAdaptiveRelativeWeightedLMS( - relative_weighted_out, - adaptive_neutral_lms)); -} - -float3x3 psycho25_WeightedLMSToRGBMatrix(int gamut_mode) { - if (gamut_mode == PSYCHO25_TARGET_GAMUT_BT709) { - return renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT709_MAT; - } - if (gamut_mode == PSYCHO25_TARGET_GAMUT_DISPLAY_P3) { - return PSYCHO25_LMS_WEIGHTED_TO_DISPLAY_P3_MAT; - } - return renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT2020_MAT; -} - -float3 psycho25_TargetRGBFromLMS(float3 lms, int gamut_mode) { - return mul( - psycho25_WeightedLMSToRGBMatrix(gamut_mode), - renodx::color::macleod_boynton::WeighLMS(lms)); -} - -float psycho25_TargetLowerPlaneBoundaryFraction( - float3 candidate_target_rgb, - float3 neutral_target_rgb) { - float boundary_fraction = PSYCHO25_LARGE; - if (candidate_target_rgb.x < neutral_target_rgb.x) { - boundary_fraction = min( - boundary_fraction, - neutral_target_rgb.x - / (neutral_target_rgb.x - candidate_target_rgb.x)); - } - if (candidate_target_rgb.y < neutral_target_rgb.y) { - boundary_fraction = min( - boundary_fraction, - neutral_target_rgb.y - / (neutral_target_rgb.y - candidate_target_rgb.y)); - } - if (candidate_target_rgb.z < neutral_target_rgb.z) { - boundary_fraction = min( - boundary_fraction, - neutral_target_rgb.z - / (neutral_target_rgb.z - candidate_target_rgb.z)); - } - return boundary_fraction; -} - -float psycho25_CompressTargetLowerPlaneRadius(float boundary_fraction) { - float knee = PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE * boundary_fraction; - float headroom = boundary_fraction - knee; - float excess = max(1.f - knee, 0.f); - return 1.f - excess - + renodx::math::DivideSafe( - headroom * excess, - headroom + excess, - 0.f); -} - -float psycho25_SmoothPositive(float value) { - float smooth_length = sqrt( - value * value - + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON - * PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); - float normalized_value = value / smooth_length; - return 0.5f * value * normalized_value * (1.f + normalized_value); -} - -float psycho25_IntersectTargetPlaneSupports(float a, float b) { - float normalization = max(a, b); - float normalized_a = a / normalization; - float normalized_b = b / normalization; - float denominator = normalization - * pow( - pow(normalized_a, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER) - + pow(normalized_b, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER), - rcp(PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER)); - return a * b / denominator; -} - -float psycho25_IntersectTargetPlaneSupports(float3 support) { - return psycho25_IntersectTargetPlaneSupports( - support.x, - psycho25_IntersectTargetPlaneSupports(support.y, support.z)); -} - -float psycho25_TargetLowerPlaneRadiusForDirection( - float2 direction, - float2 adapted_neutral_mb, - float3 current_adaptive_state_lms, - int target_gamut_mode) { - float3 neutral_lms = psycho25_LMSFromAdaptiveMB( - float3(adapted_neutral_mb, 1.f), - current_adaptive_state_lms); - float3 unit_radius_lms = psycho25_LMSFromAdaptiveMB( - float3(adapted_neutral_mb + direction, 1.f), - current_adaptive_state_lms); - float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( - neutral_lms, - target_gamut_mode); - float3 direction_target_rgb = psycho25_TargetRGBFromLMS( - unit_radius_lms - neutral_lms, - target_gamut_mode); - float3 lower_support = neutral_target_rgb - / (float3( - psycho25_SmoothPositive(-direction_target_rgb.x), - psycho25_SmoothPositive(-direction_target_rgb.y), - psycho25_SmoothPositive(-direction_target_rgb.z)) - + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); - return psycho25_IntersectTargetPlaneSupports(lower_support); -} - -} // namespace psychov -} // namespace tonemap -} // namespace renodx - -float3 ComputeCInfinityTransition(float3 position) { - position = saturate(position); - return 1.f / (1.f + exp2((1.f - 2.f * position) / (position * (1.f - position)))); -} - -// Monotonic and C-infinity continuous anchored tonal grading -float3 ApplyAnchoredTonalGrading( - float3 color, - float3 anchor_in = 0.18f, - float3 anchor_out = 0.18f, - float contrast = 1.f, - float flare = 0.f, - float highlight_contrast = 1.f, - float shadow_contrast = 1.f, - float highlights = 1.f, - float shadows = 1.f) { - [branch] - if (contrast == 1.f - && flare == 0.f - && highlight_contrast == 1.f - && shadow_contrast == 1.f - && highlights == 1.f - && shadows == 1.f - && all(anchor_in == anchor_out)) { - return color; - } - - float3 ax = abs(color); - float3 normalized = ax / anchor_in; - float3 contrasted_normalized = normalized; - - // Power contrast and shadow flare, optionally bounding contrast on highlights. - [branch] - if (contrast != 1.f || flare > 0.f) { - float3 exponent = contrast; - - [branch] - if (flare > 0.f) { - float3 shadow_distance = saturate(1.f - normalized); - float3 flat_shadow_weight = exp2(-normalized / shadow_distance); - exponent *= mad(flat_shadow_weight, flare / (normalized + flare), 1.f); - } - -#if 1 - float3 input_stops = log2(normalized); - float3 highlight_stops = max(input_stops, 0.f); - float3 output_highlight_stops = highlight_stops; - - [branch] - if (contrast != 1.f) { - float3 contrast_displacement = (contrast - 1.f) * highlight_stops; - float3 displacement_magnitude = abs(contrast_displacement); - output_highlight_stops += contrast_displacement / mad(displacement_magnitude, exp2(-1.f / displacement_magnitude), 1.f); - } - - contrasted_normalized = exp2(mad(exponent, min(input_stops, 0.f), output_highlight_stops)); -#else - contrasted_normalized = pow(normalized, exponent); -#endif - } - - // broad highlight contrast. - [branch] - if (highlight_contrast != 1.f) { - float3 highlight_distance = max(contrasted_normalized - 1.f, 0.f); - float3 highlight_distance_squared = highlight_distance * highlight_distance; - float3 flat_highlight_distance = (1.f + highlight_distance_squared) * exp2(-1.f / highlight_distance_squared); - contrasted_normalized += highlight_distance * (pow(1.f + flat_highlight_distance, 0.5f * (highlight_contrast - 1.f)) - 1.f); - } - - // broad shadow contrast. - [branch] - if (shadow_contrast != 1.f) { - float3 shadow_distance = saturate(1.f - contrasted_normalized); - float3 shadow_distance_squared = shadow_distance * shadow_distance; - float3 flat_shadow_distance = shadow_distance_squared * shadow_distance * exp2(1.f - 1.f / shadow_distance_squared); - contrasted_normalized *= pow(1.f + flat_shadow_distance, shadow_contrast - 1.f); - } - - // Mirror offsets about the anchor over the declared stop range. - [branch] - if (highlights != 1.f || shadows != 1.f) { - static const float TONAL_OFFSET_START_STOPS = 1.f; - static const float TONAL_OFFSET_END_STOPS = 8.f; - static const float TONAL_OFFSET_INVERSE_RANGE_STOPS = 1.f / (TONAL_OFFSET_END_STOPS - TONAL_OFFSET_START_STOPS); - - float3 tonal_stops = log2(contrasted_normalized); - float3 tonal_displacement = 0.f; - - [branch] - if (highlights != 1.f) { - float highlight_adjustment = highlights - 1.f; - float highlight_displacement = highlight_adjustment * mad(1.5f, abs(highlight_adjustment), 0.5f); - float3 highlight_weight = ComputeCInfinityTransition((tonal_stops - TONAL_OFFSET_START_STOPS) * TONAL_OFFSET_INVERSE_RANGE_STOPS); - tonal_displacement = mad(highlight_displacement, highlight_weight, tonal_displacement); - } - - [branch] - if (shadows != 1.f) { - float shadow_adjustment = shadows - 1.f; - float shadow_displacement = shadow_adjustment * mad(1.5f, abs(shadow_adjustment), 0.5f); - float3 shadow_weight = ComputeCInfinityTransition((-TONAL_OFFSET_START_STOPS - tonal_stops) * TONAL_OFFSET_INVERSE_RANGE_STOPS); - tonal_displacement = mad(shadow_displacement, shadow_weight, tonal_displacement); - } - - contrasted_normalized *= exp2(tonal_displacement); - } - - return renodx::math::CopySign(contrasted_normalized * anchor_out, color); -} - -/// Identity through anchor to every derivative; then approaches peak -/// monotonically and concave down. Requires anchor < peak and compression_strength >= 1. -#define APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(T) \ - T ApplyAnchoredCInfinityShoulder(T color, T peak, T anchor, float compression_strength) { \ - T shoulder_range = peak - anchor; \ - T distance_from_anchor = max(color - anchor, (T)0.f); \ - T flat_weight = exp2(-shoulder_range / (compression_strength * distance_from_anchor)); \ - T response_denominator = mad(distance_from_anchor, flat_weight, shoulder_range); \ - return mad(shoulder_range, distance_from_anchor / response_denominator, color - distance_from_anchor); \ - } - -APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float) -APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float3) -#undef APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR - -float ApplyAnchoredCInfinityShoulderMaxChannelScale(float3 color, float peak, float anchor, float compression_strength) { - float max_channel = renodx::math::Max(abs(color)); - float compressed_max = ApplyAnchoredCInfinityShoulder(max_channel, peak, anchor, compression_strength); - return renodx::math::DivideSafe(compressed_max, max_channel, 1.f); -} - -// PsychoV25 target-hull path: Fast60 hue guidance, Reference Scale, -// full target-gamut lower/upper-plane enforcement, and a black upper-hull pivot. -float3 CompressPsychoV25ReferenceScaleHull( - float3 desired_lms, - float3 direction_source_lms, - float3 adaptive_state_lms, - float3 background_state_lms, - float3 target_lms_peak, - float pre_shoulder_hue_linearity, - float post_shoulder_source_hue_recovery_strength, - float compression, - float peak_value, - int target_gamut_mode) { - float3 desired_weighted_lms = renodx::color::macleod_boynton::WeighLMS(desired_lms); - float desired_yf = desired_weighted_lms.x + desired_weighted_lms.y; - if (desired_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { - return 0.f.xxx; - } - - float adaptive_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(adaptive_state_lms); - float background_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(background_state_lms); - float target_peak_yf = renodx::tonemap::psychov::psycho25_SignedYfFromLMS(target_lms_peak); - float3 safe_adaptive_state_lms = max( - adaptive_state_lms, - renodx::tonemap::psychov::PSYCHO25_EPSILON.xxx); - float2 adapted_neutral_mb = renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - float3 source_mb = renodx::color::macleod_boynton::from::WeightedLMS( - renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( - direction_source_lms, - adaptive_state_lms)); - - // Hue linearity authors the shoulder input rather than correcting its - // output. Blend the desired adaptive-MB direction toward the source while - // retaining the desired radius and Yf, then run the per-cone shoulder. - float3 shoulder_input_lms = desired_lms; - float3 desired_mb = renodx::color::macleod_boynton::from::WeightedLMS( - renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( - desired_lms, - adaptive_state_lms)); - float2 desired_offset = desired_mb.xy - adapted_neutral_mb; - float2 source_offset = source_mb.xy - adapted_neutral_mb; - float desired_radius2 = dot(desired_offset, desired_offset); - float source_radius2 = dot(source_offset, source_offset); - if (pre_shoulder_hue_linearity > 0.f - && desired_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON - && source_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON) { - float2 desired_direction = desired_offset * rsqrt(desired_radius2); - float2 source_direction = source_offset * rsqrt(source_radius2); - float2 shoulder_input_direction = lerp( - desired_direction, - source_direction, - saturate(pre_shoulder_hue_linearity)); - shoulder_input_direction *= rsqrt( - dot(shoulder_input_direction, shoulder_input_direction)); - float2 shoulder_input_mb_xy = adapted_neutral_mb - + shoulder_input_direction * sqrt(desired_radius2); - float shoulder_input_mb_scale = renodx::math::DivideSafe( - desired_yf, - shoulder_input_mb_xy.x * safe_adaptive_state_lms.x - + (1.f - shoulder_input_mb_xy.x) * safe_adaptive_state_lms.y, - 0.f); - shoulder_input_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( - float3(shoulder_input_mb_xy, shoulder_input_mb_scale), - adaptive_state_lms); - } - - float3 physical_compressed_lms = ApplyAnchoredCInfinityShoulder( - shoulder_input_lms, - target_lms_peak, - background_state_lms, - compression); - float authored_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(physical_compressed_lms); - if (authored_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { - return 0.f.xxx; - } - - float3 authored_mb = renodx::color::macleod_boynton::from::WeightedLMS( - renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( - physical_compressed_lms, - adaptive_state_lms)); - float2 authored_offset = authored_mb.xy - adapted_neutral_mb; - float authored_radius2 = dot(authored_offset, authored_offset); - - float authored_radius = sqrt(authored_radius2); - float2 authored_direction = authored_offset * rsqrt(authored_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); - - // Reference Scale source-direction recovery keeps collapsing saturated - // highlights from rotating through an unrelated hue on their way to white. - [branch] - if (post_shoulder_source_hue_recovery_strength > 0.f) { - float source_radius = sqrt(source_radius2); - float2 source_direction = source_offset * rsqrt(source_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); - float source_radius_support = - renodx::tonemap::psychov::psycho25_TargetLowerPlaneRadiusForDirection( - source_direction, - adapted_neutral_mb, - adaptive_state_lms, - target_gamut_mode); - float source_direction_support_radius = - renodx::tonemap::psychov::PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY - * source_radius_support - * renodx::math::DivideSafe( - source_radius, - sqrt(source_radius2 + source_radius_support * source_radius_support), - 0.f); - float radius_normalization = max( - max(authored_radius, source_direction_support_radius), - renodx::tonemap::psychov::PSYCHO25_EPSILON); - float authored_weight = pow( - authored_radius / radius_normalization, - renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); - float source_direction_support_weight = pow( - source_direction_support_radius / radius_normalization, - renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); - float source_hue_support = - renodx::tonemap::psychov::PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION - * source_radius_support; - float source_hue_confidence = renodx::math::DivideSafe( - source_radius2, - source_radius2 + source_hue_support * source_hue_support, - 0.f); - float source_collapse_weight = renodx::math::DivideSafe( - source_direction_support_weight, - authored_weight + source_direction_support_weight, - 0.f); - float source_direction_weight = post_shoulder_source_hue_recovery_strength - * (1.f - (1.f - source_hue_confidence) * (1.f - source_collapse_weight)); - float2 combined_direction = lerp( - authored_direction, - source_direction, - source_direction_weight); - combined_direction *= rsqrt( - dot(combined_direction, combined_direction) - + renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON); - authored_direction = combined_direction; - authored_offset = authored_direction * authored_radius; - authored_mb.xy = adapted_neutral_mb + authored_offset; - } - - // Discard the trajectory's carried scale, preserving only its authored - // adaptive-MB direction and radius before solving the target gamut hull. - float trajectory_yf_for_normalization = authored_mb.z - * (authored_mb.x * safe_adaptive_state_lms.x - + (1.f - authored_mb.x) * safe_adaptive_state_lms.y); - float3 unit_yf_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( - float3( - authored_mb.xy, - renodx::math::DivideSafe( - authored_mb.z, - trajectory_yf_for_normalization, - 0.f)), - adaptive_state_lms); - float3 neutral_lms = adaptive_state_lms / adaptive_yf; - - // Reference Scale lower-plane compression keeps the authored hue ray inside - // the nonnegative target-gamut primary half-spaces without a component clamp. - if (authored_radius > renodx::tonemap::psychov::PSYCHO25_EPSILON) { - float3 neutral_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(neutral_lms, target_gamut_mode); - float3 current_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, target_gamut_mode); - float current_boundary_fraction = - renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( - current_target_rgb, - neutral_target_rgb); - float current_radius_scale = - renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( - current_boundary_fraction); - - authored_direction = authored_offset / authored_radius; - float containment_reference_radius = max( - authored_radius, - length(source_mb.xy - adapted_neutral_mb)); - float3 reference_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( - float3( - adapted_neutral_mb - + authored_direction * containment_reference_radius, - 1.f), - adaptive_state_lms); - reference_lms /= renodx::tonemap::psychov::psycho25_YfFromLMS(reference_lms); - float3 reference_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(reference_lms, target_gamut_mode); - float reference_boundary_fraction = - renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( - reference_target_rgb, - neutral_target_rgb); - float reference_radius_scale = - renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( - reference_boundary_fraction); - - float trajectory_fraction = authored_radius / containment_reference_radius; - float release_progress = saturate( - trajectory_fraction - / renodx::tonemap::psychov::PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION); - float neutral_scale = min(1.f, 4.f * reference_radius_scale); - float release_weight = 1.f - release_progress; - float radius_scale = min( - lerp( - reference_radius_scale, - neutral_scale, - release_weight * release_weight), - current_radius_scale); - unit_yf_lms = lerp(neutral_lms, unit_yf_lms, radius_scale); - } - - // Black-pivot upper-plane shoulder along the contained target-gamut hue ray. - float3 unit_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, target_gamut_mode); - float max_target_channel = max( - unit_target_rgb.x, - max(unit_target_rgb.y, unit_target_rgb.z)); - float directional_yf_limit = peak_value / max_target_channel; - float normalized_input = desired_yf * renodx::math::DivideSafe(target_peak_yf, directional_yf_limit, 1.f); - float normalized_output = ApplyAnchoredCInfinityShoulder( - normalized_input, - target_peak_yf, - background_yf, - compression); - float output_yf = normalized_output * renodx::math::DivideSafe(directional_yf_limit, target_peak_yf, 1.f); - return unit_yf_lms * output_yf; -} - -float3 ApplyCustomPsychoV25ToneMap( - float3 bt709_linear_input, - float peak_value, - float highlights, - float shadows, - float cone_response_exponent, - float flare, - float purity_scale, - float highlight_saturation, - float dechroma, - float3 current_adaptive_state_bt709 = 0.18f, - float3 current_background_state_bt709 = 0.18f, - float pre_shoulder_hue_linearity = 0.5f, - float post_shoulder_source_hue_recovery_strength = 0.35f, - float compression = 1.5f, - int target_gamut_mode = renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_BT2020) { - float3 finite_bt709_input = renodx::math::ZeroNaN(bt709_linear_input); - finite_bt709_input = renodx::math::Select( - isinf(finite_bt709_input), - renodx::math::CopySign(65504.f.xxx, finite_bt709_input), - finite_bt709_input); - - float3 lms_in = renodx::color::lms::from::BT709(finite_bt709_input); - float3 current_adaptive_state_lms = renodx::color::lms::from::BT709(current_adaptive_state_bt709); - float3 current_background_state_lms = renodx::color::lms::from::BT709(current_background_state_bt709); - float3 target_lms_peak = renodx::color::lms::from::BT709(peak_value.xxx); - - if (dechroma != 0.f || highlight_saturation != 1.f) { - float luminance = renodx::color::yf::from::LMS(lms_in); - float neutral_luminance = renodx::color::yf::from::LMS(current_adaptive_state_lms); - - // Ramp purity grading over 2.75 decades above the adaptive neutral. - static const float INVERSE_HIGHLIGHT_RANGE_STOPS = 1.f / (2.75f * log2(10.f)); - static const float HIGHLIGHT_ROLLOFF_CUBIC_BLEND = 0.5f; - static const float HIGHLIGHT_PURITY_STRENGTH = 2.f / 3.f; - - float luminance_from_neutral = max(luminance, neutral_luminance) / neutral_luminance; - float rolloff_position = saturate(log2(luminance_from_neutral) * INVERSE_HIGHLIGHT_RANGE_STOPS); - float rolloff_position_squared = rolloff_position * rolloff_position; - float rolloff = rolloff_position_squared * rolloff_position * mad(rolloff_position, mad(6.f, rolloff_position, -15.f), 10.f); - - // Base smootherstep brings dechroma into the midtones while remaining monotonic and C2. - if (dechroma != 0.f) { - purity_scale *= mad(-dechroma, rolloff, 1.f); - } - - // Blend smootherstep squared and cubed for a later, gentler C2 progression. - if (highlight_saturation != 1.f) { - float highlight_rolloff = rolloff * rolloff * mad(HIGHLIGHT_ROLLOFF_CUBIC_BLEND, rolloff, 1.f - HIGHLIGHT_ROLLOFF_CUBIC_BLEND); - purity_scale *= mad(highlight_saturation - 1.f, highlight_rolloff * HIGHLIGHT_PURITY_STRENGTH, 1.f); - } - } - - float3 contrast_input = renodx::tonemap::psychov::psycho25_ApplyAdaptiveMBPurity( - lms_in, - current_adaptive_state_lms, - purity_scale); - float3 contrast_lms = ApplyAnchoredTonalGrading( - contrast_input, - current_adaptive_state_lms, - current_background_state_lms, - cone_response_exponent, - flare, - 1.f, - 1.f, - highlights, - shadows); - - float3 output_lms = CompressPsychoV25ReferenceScaleHull( - contrast_lms, - contrast_input, - current_adaptive_state_lms, - current_background_state_lms, - target_lms_peak, - pre_shoulder_hue_linearity, - post_shoulder_source_hue_recovery_strength, - compression, - peak_value, - target_gamut_mode); - return renodx::color::bt709::from::LMS(output_lms); -} - -#endif // RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ \ No newline at end of file diff --git a/src/games/elitedangerous/tonemap/tonemap.hlsli b/src/games/elitedangerous/tonemap/tonemap.hlsli index 13c07a3d8..1fe5bcc95 100644 --- a/src/games/elitedangerous/tonemap/tonemap.hlsli +++ b/src/games/elitedangerous/tonemap/tonemap.hlsli @@ -1,5 +1,5 @@ #include "../common.hlsli" -#include "./psychov25/customtest25.hlsli" +#include "./psychov/customtest30.hlsli" static const float MID_GRAY_IN = 0.119121851127f; static const float MID_GRAY_OUT = 0.163979921774f; @@ -215,23 +215,25 @@ float3 ApplyPostLUTToneMap(float3 untonemapped_gamma) { ApplyAnchoredCInfinityShoulder(abs(untonemapped), RENODX_PEAK_WHITE_NITS / RENODX_DIFFUSE_WHITE_NITS, MID_GRAY_OUT, 1.5f), untonemapped); } else { // Custom - - tonemapped = ApplyCustomPsychoV25ToneMap( + tonemapped = renodx::tonemap::psychov::psychotm_custom_test30( untonemapped, RENODX_PEAK_WHITE_NITS / RENODX_DIFFUSE_WHITE_NITS, + 1.f, RENODX_TONE_MAP_HIGHLIGHTS, RENODX_TONE_MAP_SHADOWS, 1.55f * RENODX_TONE_MAP_CONTRAST, 0.10f * pow(0.85f, 10.f) + 0.10f * pow(RENODX_TONE_MAP_FLARE, 10.f), + 1.f, + 1.f, RENODX_TONE_MAP_SATURATION, RENODX_TONE_MAP_HIGHLIGHT_SATURATION, RENODX_TONE_MAP_DECHROMA, MID_GRAY_IN, MID_GRAY_OUT, - 0.35f, - 0.3, + 1.f, + renodx::tonemap::psychov::PSYCHO30_TARGET_GAMUT_DISPLAY_P3, 1.5f, - renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_DISPLAY_P3); + 0.7f); } return renodx::color::gamma::EncodeSafe(tonemapped, 2.2f); From e4bfbc3a6ff22afd71b1572f31455947f12f4618 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Sun, 23 Aug 2026 22:32:48 -0400 Subject: [PATCH 20/22] feat(asscreedblackflagresynced): update to custom psychov30, set Enhanced to default --- src/games/asscreedblackflagresynced/addon.cpp | 4 +- .../tonemap/customtest25.hlsli | 690 ------ .../tonemap/customtest30.hlsli | 1973 +++++++++++++++++ .../tonemap/tonemap.hlsli | 141 +- 4 files changed, 2059 insertions(+), 749 deletions(-) delete mode 100644 src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli create mode 100644 src/games/asscreedblackflagresynced/tonemap/customtest30.hlsli diff --git a/src/games/asscreedblackflagresynced/addon.cpp b/src/games/asscreedblackflagresynced/addon.cpp index 4ecfa6d4b..7164de2f7 100644 --- a/src/games/asscreedblackflagresynced/addon.cpp +++ b/src/games/asscreedblackflagresynced/addon.cpp @@ -135,11 +135,11 @@ renodx::utils::settings::Settings settings = { .key = "ToneMapType", .binding = &shader_injection.tone_map_type, .value_type = renodx::utils::settings::SettingValueType::INTEGER, - .default_value = 1.f, + .default_value = 2.f, .label = "Tone Mapper", .section = "Tone Mapping", .tooltip = "Sets the tone mapper type. Toggle in-game HDR setting or restart game to take effect.", - .labels = {"Vanilla", "RenoDX (Vanilla+)", "RenoDX (Customized)"}, + .labels = {"Vanilla", "RenoDX (Vanilla+)", "RenoDX (Enhanced)"}, .on_change_value = &OnToneMapLutControlledSettingChanged, }, new renodx::utils::settings::Setting{ diff --git a/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli b/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli deleted file mode 100644 index a642f17f7..000000000 --- a/src/games/asscreedblackflagresynced/tonemap/customtest25.hlsli +++ /dev/null @@ -1,690 +0,0 @@ -#ifndef RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ -#define RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ - -#include "../common.hlsli" - -/* - * Copyright (C) 2026 Carlos Lopez - * SPDX-License-Identifier: MIT - */ - -namespace renodx { -namespace tonemap { -namespace psychov { - -static const float PSYCHO25_EPSILON = 1e-6f; -static const float PSYCHO25_LARGE = 1e20f; -static const float PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE = 0.9f; -static const float PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION = 0.75f; -static const float PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON = 1e-5f; -static const float PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER = 256.f; -static const float PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY = 0.8f; -static const float PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION = 0.25f; -static const float PSYCHO25_SOURCE_DIRECTION_BLEND_POWER = 2.f; - -static const int PSYCHO25_TARGET_GAMUT_BT709 = 0; -static const int PSYCHO25_TARGET_GAMUT_BT2020 = 1; -static const int PSYCHO25_TARGET_GAMUT_DISPLAY_P3 = 3; - -static const float3x3 PSYCHO25_LMS_WEIGHTED_TO_DISPLAY_P3_MAT = mul(renodx::color::XYZ_TO_DISPLAYP3_MAT, renodx::color::macleod_boynton::LMS_WEIGHTED_TO_XYZ_MAT); - -float psycho25_SignedYfFromLMS(float3 lms) { - float3 weighted_lms = renodx::color::macleod_boynton::WeighLMS(lms); - return weighted_lms.x + weighted_lms.y; -} - -float psycho25_YfFromLMS(float3 lms) { - return max(psycho25_SignedYfFromLMS(lms), PSYCHO25_EPSILON); -} - -float3 psycho25_ToAdaptiveRelativeWeightedLMS( - float3 lms_input, - float3 current_adaptive_state_lms) { - return renodx::math::DivideSafe( - renodx::color::macleod_boynton::WeighLMS(lms_input), - current_adaptive_state_lms, - 0.f.xxx); -} - -float3 psycho25_FromAdaptiveRelativeWeightedLMS( - float3 lms_weighted_relative, - float3 current_adaptive_state_lms) { - return lms_weighted_relative - * max(current_adaptive_state_lms, PSYCHO25_EPSILON.xxx); -} - -float3 psycho25_LMSFromAdaptiveMB( - float3 mb, - float3 current_adaptive_state_lms) { - float3 relative_weighted = - renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton(mb); - return renodx::color::macleod_boynton::UnweighLMS( - psycho25_FromAdaptiveRelativeWeightedLMS( - relative_weighted, - current_adaptive_state_lms)); -} - -float3 psycho25_ApplyAdaptiveMBPurity( - float3 lms_input, - float3 adaptive_neutral_lms, - float purity_delta) { - if (abs(purity_delta - 1.f) <= 1e-5f) return lms_input; - - float3 relative_weighted = psycho25_ToAdaptiveRelativeWeightedLMS( - lms_input, - adaptive_neutral_lms); - float3 mb = renodx::color::macleod_boynton::from::WeightedLMS( - relative_weighted); - float3 mb_neutral = renodx::color::macleod_boynton::from::LMS(1.f.xxx); - float2 mb_scaled_xy = lerp(mb_neutral.xy, mb.xy, purity_delta); - float3 relative_weighted_out = - renodx::color::macleod_boynton::WeightedLMSFromMacleodBoynton( - float3(mb_scaled_xy, mb.z)); - return renodx::color::macleod_boynton::UnweighLMS( - psycho25_FromAdaptiveRelativeWeightedLMS( - relative_weighted_out, - adaptive_neutral_lms)); -} - -float3x3 psycho25_WeightedLMSToRGBMatrix(int gamut_mode) { - if (gamut_mode == PSYCHO25_TARGET_GAMUT_BT709) { - return renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT709_MAT; - } - if (gamut_mode == PSYCHO25_TARGET_GAMUT_DISPLAY_P3) { - return PSYCHO25_LMS_WEIGHTED_TO_DISPLAY_P3_MAT; - } - return renodx::color::macleod_boynton::LMS_WEIGHTED_TO_BT2020_MAT; -} - -float3 psycho25_TargetRGBFromLMS(float3 lms, int gamut_mode) { - return mul( - psycho25_WeightedLMSToRGBMatrix(gamut_mode), - renodx::color::macleod_boynton::WeighLMS(lms)); -} - -float psycho25_TargetLowerPlaneBoundaryFraction( - float3 candidate_target_rgb, - float3 neutral_target_rgb) { - float boundary_fraction = PSYCHO25_LARGE; - if (candidate_target_rgb.x < neutral_target_rgb.x) { - boundary_fraction = min( - boundary_fraction, - neutral_target_rgb.x - / (neutral_target_rgb.x - candidate_target_rgb.x)); - } - if (candidate_target_rgb.y < neutral_target_rgb.y) { - boundary_fraction = min( - boundary_fraction, - neutral_target_rgb.y - / (neutral_target_rgb.y - candidate_target_rgb.y)); - } - if (candidate_target_rgb.z < neutral_target_rgb.z) { - boundary_fraction = min( - boundary_fraction, - neutral_target_rgb.z - / (neutral_target_rgb.z - candidate_target_rgb.z)); - } - return boundary_fraction; -} - -float psycho25_CompressTargetLowerPlaneRadius(float boundary_fraction) { - float knee = PSYCHO25_LOWER_PLANE_COMPRESSION_KNEE * boundary_fraction; - float headroom = boundary_fraction - knee; - float excess = max(1.f - knee, 0.f); - return 1.f - excess - + renodx::math::DivideSafe( - headroom * excess, - headroom + excess, - 0.f); -} - -float psycho25_SmoothPositive(float value) { - float smooth_length = sqrt( - value * value - + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON - * PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); - float normalized_value = value / smooth_length; - return 0.5f * value * normalized_value * (1.f + normalized_value); -} - -float psycho25_IntersectTargetPlaneSupports(float a, float b) { - float normalization = max(a, b); - float normalized_a = a / normalization; - float normalized_b = b / normalization; - float denominator = normalization - * pow( - pow(normalized_a, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER) - + pow(normalized_b, PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER), - rcp(PSYCHO25_HULL_SUPPORT_INTERSECTION_POWER)); - return a * b / denominator; -} - -float psycho25_IntersectTargetPlaneSupports(float3 support) { - return psycho25_IntersectTargetPlaneSupports( - support.x, - psycho25_IntersectTargetPlaneSupports(support.y, support.z)); -} - -float psycho25_TargetLowerPlaneRadiusForDirection( - float2 direction, - float2 adapted_neutral_mb, - float3 current_adaptive_state_lms, - int target_gamut_mode) { - float3 neutral_lms = psycho25_LMSFromAdaptiveMB( - float3(adapted_neutral_mb, 1.f), - current_adaptive_state_lms); - float3 unit_radius_lms = psycho25_LMSFromAdaptiveMB( - float3(adapted_neutral_mb + direction, 1.f), - current_adaptive_state_lms); - float3 neutral_target_rgb = psycho25_TargetRGBFromLMS( - neutral_lms, - target_gamut_mode); - float3 direction_target_rgb = psycho25_TargetRGBFromLMS( - unit_radius_lms - neutral_lms, - target_gamut_mode); - float3 lower_support = neutral_target_rgb - / (float3( - psycho25_SmoothPositive(-direction_target_rgb.x), - psycho25_SmoothPositive(-direction_target_rgb.y), - psycho25_SmoothPositive(-direction_target_rgb.z)) - + PSYCHO25_HULL_SMOOTH_SUPPORT_EPSILON); - return psycho25_IntersectTargetPlaneSupports(lower_support); -} - -} // namespace psychov -} // namespace tonemap -} // namespace renodx - -float3 ComputeCInfinityTransition(float3 position) { - position = saturate(position); - return 1.f / (1.f + exp2((1.f - 2.f * position) / (position * (1.f - position)))); -} - -// Monotonic and C-infinity continuous anchored tonal grading -float3 ApplyAnchoredTonalGrading( - float3 color, - float3 anchor_in = 0.18f, - float3 anchor_out = 0.18f, - float contrast = 1.f, - float flare = 0.f, - float highlight_contrast = 1.f, - float shadow_contrast = 1.f, - float highlights = 1.f, - float shadows = 1.f) { - [branch] - if (contrast == 1.f - && flare == 0.f - && highlight_contrast == 1.f - && shadow_contrast == 1.f - && highlights == 1.f - && shadows == 1.f - && all(anchor_in == anchor_out)) { - return color; - } - - float3 ax = abs(color); - float3 normalized = ax / anchor_in; - float3 contrasted_normalized = normalized; - - // Power contrast and shadow flare, optionally bounding contrast on highlights. - [branch] - if (contrast != 1.f || flare > 0.f) { - float3 exponent = contrast; - - [branch] - if (flare > 0.f) { - float3 shadow_distance = saturate(1.f - normalized); - float3 flat_shadow_weight = exp2(-normalized / shadow_distance); - exponent *= mad(flat_shadow_weight, flare / (normalized + flare), 1.f); - } - -#if 1 - float3 input_stops = log2(normalized); - float3 highlight_stops = max(input_stops, 0.f); - float3 output_highlight_stops = highlight_stops; - - [branch] - if (contrast != 1.f) { - float3 contrast_displacement = (contrast - 1.f) * highlight_stops; - float3 displacement_magnitude = abs(contrast_displacement); - output_highlight_stops += contrast_displacement / mad(displacement_magnitude, exp2(-1.f / displacement_magnitude), 1.f); - } - - contrasted_normalized = exp2(mad(exponent, min(input_stops, 0.f), output_highlight_stops)); -#else - contrasted_normalized = pow(normalized, exponent); -#endif - } - - // broad highlight contrast. - [branch] - if (highlight_contrast != 1.f) { - float3 highlight_distance = max(contrasted_normalized - 1.f, 0.f); - float3 highlight_distance_squared = highlight_distance * highlight_distance; - float3 flat_highlight_distance = (1.f + highlight_distance_squared) * exp2(-1.f / highlight_distance_squared); - contrasted_normalized += highlight_distance * (pow(1.f + flat_highlight_distance, 0.5f * (highlight_contrast - 1.f)) - 1.f); - } - - // broad shadow contrast. - [branch] - if (shadow_contrast != 1.f) { - float3 shadow_distance = saturate(1.f - contrasted_normalized); - float3 shadow_distance_squared = shadow_distance * shadow_distance; - float3 flat_shadow_distance = shadow_distance_squared * shadow_distance * exp2(1.f - 1.f / shadow_distance_squared); - contrasted_normalized *= pow(1.f + flat_shadow_distance, shadow_contrast - 1.f); - } - - // Mirror offsets about the anchor over the declared stop range. - [branch] - if (highlights != 1.f || shadows != 1.f) { - static const float TONAL_OFFSET_START_STOPS = 1.f; - static const float TONAL_OFFSET_END_STOPS = 8.f; - static const float TONAL_OFFSET_INVERSE_RANGE_STOPS = 1.f / (TONAL_OFFSET_END_STOPS - TONAL_OFFSET_START_STOPS); - - float3 tonal_stops = log2(contrasted_normalized); - float3 tonal_displacement = 0.f; - - [branch] - if (highlights != 1.f) { - float highlight_adjustment = highlights - 1.f; - float highlight_displacement = highlight_adjustment * mad(1.5f, abs(highlight_adjustment), 0.5f); - float3 highlight_weight = ComputeCInfinityTransition((tonal_stops - TONAL_OFFSET_START_STOPS) * TONAL_OFFSET_INVERSE_RANGE_STOPS); - tonal_displacement = mad(highlight_displacement, highlight_weight, tonal_displacement); - } - - [branch] - if (shadows != 1.f) { - float shadow_adjustment = shadows - 1.f; - float shadow_displacement = shadow_adjustment * mad(1.5f, abs(shadow_adjustment), 0.5f); - float3 shadow_weight = ComputeCInfinityTransition((-TONAL_OFFSET_START_STOPS - tonal_stops) * TONAL_OFFSET_INVERSE_RANGE_STOPS); - tonal_displacement = mad(shadow_displacement, shadow_weight, tonal_displacement); - } - - contrasted_normalized *= exp2(tonal_displacement); - } - - return renodx::math::CopySign(contrasted_normalized * anchor_out, color); -} - -/// Identity through anchor to every derivative; then approaches peak -/// monotonically and concave down. Requires anchor < peak and compression_strength >= 1. -#define APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(T) \ - T ApplyAnchoredCInfinityShoulder(T color, T peak, T anchor, float compression_strength) { \ - T shoulder_range = peak - anchor; \ - T distance_from_anchor = max(color - anchor, (T)0.f); \ - T flat_weight = exp2(-shoulder_range / (compression_strength * distance_from_anchor)); \ - T response_denominator = mad(distance_from_anchor, flat_weight, shoulder_range); \ - return mad(shoulder_range, distance_from_anchor / response_denominator, color - distance_from_anchor); \ - } - -APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float) -APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR(float3) -#undef APPLYANCHORED_CINFINITY_SHOULDER_GENERATOR - -float ApplyAnchoredCInfinityShoulderMaxChannelScale(float3 color, float peak, float anchor, float compression_strength) { - float max_channel = renodx::math::Max(abs(color)); - float compressed_max = ApplyAnchoredCInfinityShoulder(max_channel, peak, anchor, compression_strength); - return renodx::math::DivideSafe(compressed_max, max_channel, 1.f); -} - -/// Identity through anchor; then approaches peak monotonically and concave down. -/// The anchor join is C2 continuous. Requires anchor < peak and compression_strength >= 1. -#define APPLYANCHOREDCUBICSHOULDER_GENERATOR(T) \ - T ApplyAnchoredCubicShoulder(T color, T peak, T anchor, float compression_strength) { \ - T shoulder_range = peak - anchor; \ - T distance_from_anchor = max(color - anchor, (T)0.f); \ - T weighted_distance = compression_strength * distance_from_anchor; \ - T response_numerator = distance_from_anchor * (shoulder_range + weighted_distance); \ - T response_denominator = mad( \ - shoulder_range, shoulder_range, weighted_distance * (shoulder_range + distance_from_anchor)); \ - return mad(shoulder_range, response_numerator / response_denominator, color - distance_from_anchor); \ - } - -/// Identity through anchor; reaches peak at clip, then remains flat. -/// Monotonic, concave down, and C2 when clip meets the calculated minimum. -#define APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR(T) \ - T ApplyAnchoredCubicShoulder( \ - T color, T peak, T anchor, float compression_strength, T clip) { \ - T shoulder_range = peak - anchor; \ - T distance_from_anchor = max(color - anchor, (T)0.f); \ - T input_range = clip - anchor; \ - T clipped_distance = min(distance_from_anchor, input_range); \ - T clip_position = clipped_distance / input_range; \ - T clip_position_squared = clip_position * clip_position; \ - T clip_position_cubed = clip_position_squared * clip_position; \ - T residual_weight = (T)1.f - clip_position_cubed * mad(clip_position, mad((T)6.f, clip_position, (T) - 15.f), (T)10.f); \ - T weighted_distance = compression_strength * clipped_distance; \ - T response_numerator = clipped_distance * (shoulder_range + weighted_distance); \ - T remaining_distance = shoulder_range * mad(compression_strength - 1.f, clipped_distance, shoulder_range); \ - T response_denominator = mad(residual_weight, remaining_distance, response_numerator); \ - return mad(shoulder_range, response_numerator / response_denominator, color - distance_from_anchor); \ - } - -APPLYANCHOREDCUBICSHOULDER_GENERATOR(float) -APPLYANCHOREDCUBICSHOULDER_GENERATOR(float3) -APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR(float) -APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR(float3) -#undef APPLYANCHOREDCUBICSHOULDER_GENERATOR -#undef APPLYANCHOREDCUBICSHOULDER_CLIP_GENERATOR - -// PsychoV25 target-hull path: Fast60 hue guidance, Reference Scale, -// full target-gamut lower/upper-plane enforcement, and a black upper-hull pivot. -float3 CompressPsychoV25ReferenceScaleHull( - float3 desired_lms, - float3 direction_source_lms, - float3 adaptive_state_lms, - float3 background_state_lms, - float3 target_lms_peak, - float pre_shoulder_hue_linearity, - float post_shoulder_source_hue_recovery_strength, - float post_saturation, - float compression, - float peak_value, - int target_gamut_mode) { - float3 desired_weighted_lms = renodx::color::macleod_boynton::WeighLMS(desired_lms); - float desired_yf = desired_weighted_lms.x + desired_weighted_lms.y; - if (desired_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { - return 0.f.xxx; - } - - float adaptive_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(adaptive_state_lms); - float background_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(background_state_lms); - float target_peak_yf = renodx::tonemap::psychov::psycho25_SignedYfFromLMS(target_lms_peak); - float3 safe_adaptive_state_lms = max( - adaptive_state_lms, - renodx::tonemap::psychov::PSYCHO25_EPSILON.xxx); - float2 adapted_neutral_mb = renodx::color::macleod_boynton::from::LMS(1.f.xxx).xy; - float3 source_mb = renodx::color::macleod_boynton::from::WeightedLMS( - renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( - direction_source_lms, - adaptive_state_lms)); - - // Hue linearity authors the shoulder input rather than correcting its - // output. Blend the desired adaptive-MB direction toward the source while - // retaining the desired radius and Yf, then run the per-cone shoulder. - float3 shoulder_input_lms = desired_lms; - float3 desired_mb = renodx::color::macleod_boynton::from::WeightedLMS( - renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( - desired_lms, - adaptive_state_lms)); - float2 desired_offset = desired_mb.xy - adapted_neutral_mb; - float2 source_offset = source_mb.xy - adapted_neutral_mb; - float desired_radius2 = dot(desired_offset, desired_offset); - float source_radius2 = dot(source_offset, source_offset); - if (pre_shoulder_hue_linearity > 0.f - && desired_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON - && source_radius2 > renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON) { - float2 desired_direction = desired_offset * rsqrt(desired_radius2); - float2 source_direction = source_offset * rsqrt(source_radius2); - float2 shoulder_input_direction = lerp( - desired_direction, - source_direction, - saturate(pre_shoulder_hue_linearity)); - shoulder_input_direction *= rsqrt( - dot(shoulder_input_direction, shoulder_input_direction)); - float2 shoulder_input_mb_xy = adapted_neutral_mb - + shoulder_input_direction * sqrt(desired_radius2); - float shoulder_input_mb_scale = renodx::math::DivideSafe( - desired_yf, - shoulder_input_mb_xy.x * safe_adaptive_state_lms.x - + (1.f - shoulder_input_mb_xy.x) * safe_adaptive_state_lms.y, - 0.f); - shoulder_input_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( - float3(shoulder_input_mb_xy, shoulder_input_mb_scale), - adaptive_state_lms); - } - - float3 physical_compressed_lms = ApplyAnchoredCInfinityShoulder( - shoulder_input_lms, - target_lms_peak, - background_state_lms, - compression); - float authored_yf = renodx::tonemap::psychov::psycho25_YfFromLMS(physical_compressed_lms); - if (authored_yf <= renodx::tonemap::psychov::PSYCHO25_EPSILON) { - return 0.f.xxx; - } - - float3 authored_mb = renodx::color::macleod_boynton::from::WeightedLMS( - renodx::tonemap::psychov::psycho25_ToAdaptiveRelativeWeightedLMS( - physical_compressed_lms, - adaptive_state_lms)); - float2 authored_offset = authored_mb.xy - adapted_neutral_mb; - float authored_radius2 = dot(authored_offset, authored_offset); - - float authored_radius = sqrt(authored_radius2); - float2 authored_direction = authored_offset * rsqrt(authored_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); - - // Reference Scale source-direction recovery keeps collapsing saturated - // highlights from rotating through an unrelated hue on their way to white. - [branch] - if (post_shoulder_source_hue_recovery_strength > 0.f) { - float source_radius = sqrt(source_radius2); - float2 source_direction = source_offset * rsqrt(source_radius2 + renodx::tonemap::psychov::PSYCHO25_EPSILON * renodx::tonemap::psychov::PSYCHO25_EPSILON); - float source_radius_support = - renodx::tonemap::psychov::psycho25_TargetLowerPlaneRadiusForDirection( - source_direction, - adapted_neutral_mb, - adaptive_state_lms, - target_gamut_mode); - float source_direction_support_radius = - renodx::tonemap::psychov::PSYCHO25_REFERENCE_SOURCE_DIRECTION_OCCUPANCY - * source_radius_support - * renodx::math::DivideSafe( - source_radius, - sqrt(source_radius2 + source_radius_support * source_radius_support), - 0.f); - float radius_normalization = max( - max(authored_radius, source_direction_support_radius), - renodx::tonemap::psychov::PSYCHO25_EPSILON); - float authored_weight = pow( - authored_radius / radius_normalization, - renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); - float source_direction_support_weight = pow( - source_direction_support_radius / radius_normalization, - renodx::tonemap::psychov::PSYCHO25_SOURCE_DIRECTION_BLEND_POWER); - float source_hue_support = - renodx::tonemap::psychov::PSYCHO25_SOURCE_HUE_SUPPORT_FRACTION - * source_radius_support; - float source_hue_confidence = renodx::math::DivideSafe( - source_radius2, - source_radius2 + source_hue_support * source_hue_support, - 0.f); - float source_collapse_weight = renodx::math::DivideSafe( - source_direction_support_weight, - authored_weight + source_direction_support_weight, - 0.f); - float source_direction_weight = post_shoulder_source_hue_recovery_strength - * (1.f - (1.f - source_hue_confidence) * (1.f - source_collapse_weight)); - float2 combined_direction = lerp( - authored_direction, - source_direction, - source_direction_weight); - combined_direction *= rsqrt( - dot(combined_direction, combined_direction) - + renodx::tonemap::psychov::PSYCHO25_EPSILON - * renodx::tonemap::psychov::PSYCHO25_EPSILON); - authored_direction = combined_direction; - authored_offset = authored_direction * authored_radius; - authored_mb.xy = adapted_neutral_mb + authored_offset; - } - - // Adjust saturation only after Fast60 and source-direction recovery have - // authored the hue, but before Reference Scale gamut containment. - [branch] - if (post_saturation != 1.f) { - float saturation_scale = max(post_saturation, 0.f); - authored_radius *= saturation_scale; - authored_offset = authored_direction * authored_radius; - authored_mb.xy = adapted_neutral_mb + authored_offset; - source_offset *= saturation_scale; - source_mb.xy = adapted_neutral_mb + source_offset; - } - - // Discard the trajectory's carried scale, preserving only its authored - // adaptive-MB direction and radius before solving the target gamut hull. - float trajectory_yf_for_normalization = authored_mb.z - * (authored_mb.x * safe_adaptive_state_lms.x - + (1.f - authored_mb.x) * safe_adaptive_state_lms.y); - float3 unit_yf_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( - float3( - authored_mb.xy, - renodx::math::DivideSafe( - authored_mb.z, - trajectory_yf_for_normalization, - 0.f)), - adaptive_state_lms); - float3 neutral_lms = adaptive_state_lms / adaptive_yf; - - // Reference Scale lower-plane compression keeps the authored hue ray inside - // the nonnegative target-gamut primary half-spaces without a component clamp. - if (authored_radius > renodx::tonemap::psychov::PSYCHO25_EPSILON) { - float3 neutral_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(neutral_lms, target_gamut_mode); - float3 current_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, target_gamut_mode); - float current_boundary_fraction = - renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( - current_target_rgb, - neutral_target_rgb); - float current_radius_scale = - renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( - current_boundary_fraction); - - authored_direction = authored_offset / authored_radius; - float containment_reference_radius = max( - authored_radius, - length(source_mb.xy - adapted_neutral_mb)); - float3 reference_lms = renodx::tonemap::psychov::psycho25_LMSFromAdaptiveMB( - float3( - adapted_neutral_mb - + authored_direction * containment_reference_radius, - 1.f), - adaptive_state_lms); - reference_lms /= renodx::tonemap::psychov::psycho25_YfFromLMS(reference_lms); - float3 reference_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(reference_lms, target_gamut_mode); - float reference_boundary_fraction = - renodx::tonemap::psychov::psycho25_TargetLowerPlaneBoundaryFraction( - reference_target_rgb, - neutral_target_rgb); - float reference_radius_scale = - renodx::tonemap::psychov::psycho25_CompressTargetLowerPlaneRadius( - reference_boundary_fraction); - - float trajectory_fraction = authored_radius / containment_reference_radius; - float release_progress = saturate( - trajectory_fraction - / renodx::tonemap::psychov::PSYCHO25_LOWER_PLANE_NEUTRAL_RELEASE_FRACTION); - float neutral_scale = min(1.f, 4.f * reference_radius_scale); - float release_weight = 1.f - release_progress; - float radius_scale = min( - lerp( - reference_radius_scale, - neutral_scale, - release_weight * release_weight), - current_radius_scale); - unit_yf_lms = lerp(neutral_lms, unit_yf_lms, radius_scale); - } - - // Black-pivot upper-plane shoulder along the contained target-gamut hue ray. - float3 unit_target_rgb = - renodx::tonemap::psychov::psycho25_TargetRGBFromLMS(unit_yf_lms, target_gamut_mode); - float max_target_channel = max( - unit_target_rgb.x, - max(unit_target_rgb.y, unit_target_rgb.z)); - float directional_yf_limit = peak_value / max_target_channel; - float normalized_input = desired_yf * renodx::math::DivideSafe(target_peak_yf, directional_yf_limit, 1.f); - float normalized_output = ApplyAnchoredCInfinityShoulder( - normalized_input, - target_peak_yf, - background_yf, - compression); - float output_yf = normalized_output * renodx::math::DivideSafe(directional_yf_limit, target_peak_yf, 1.f); - return unit_yf_lms * output_yf; -} - -float3 ApplyCustomPsychoV25ToneMap( - float3 bt709_linear_input, - float peak_value, - float highlights, - float shadows, - float cone_response_exponent, - float flare, - float purity_scale, - float highlight_saturation, - float dechroma, - float pre_shoulder_hue_linearity = 0.35f, - float post_shoulder_source_hue_recovery_strength = 0.f, - float3 current_adaptive_state_bt709 = 0.18f, - float3 current_background_state_bt709 = 0.18f, - float compression = 1.5f) { - float3 finite_bt709_input = renodx::math::ZeroNaN(bt709_linear_input); - finite_bt709_input = renodx::math::Select( - isinf(finite_bt709_input), - renodx::math::CopySign(65504.f.xxx, finite_bt709_input), - finite_bt709_input); - - float3 lms_in = renodx::color::lms::from::BT709(finite_bt709_input); - float3 current_adaptive_state_lms = renodx::color::lms::from::BT709(current_adaptive_state_bt709); - float3 current_background_state_lms = renodx::color::lms::from::BT709(current_background_state_bt709); - float3 target_lms_peak = renodx::color::lms::from::BT709(peak_value.xxx); - - if (dechroma != 0.f || highlight_saturation != 1.f) { - float luminance = renodx::color::yf::from::LMS(lms_in); - float neutral_luminance = renodx::color::yf::from::LMS(current_adaptive_state_lms); - - // Ramp purity grading over 2.75 decades above the adaptive neutral. - static const float INVERSE_HIGHLIGHT_RANGE_STOPS = 1.f / (2.75f * log2(10.f)); - static const float HIGHLIGHT_ROLLOFF_CUBIC_BLEND = 0.5f; - static const float HIGHLIGHT_PURITY_STRENGTH = 2.f / 3.f; - - float luminance_from_neutral = max(luminance, neutral_luminance) / neutral_luminance; - float rolloff_position = saturate(log2(luminance_from_neutral) * INVERSE_HIGHLIGHT_RANGE_STOPS); - float rolloff_position_squared = rolloff_position * rolloff_position; - float rolloff = rolloff_position_squared * rolloff_position * mad(rolloff_position, mad(6.f, rolloff_position, -15.f), 10.f); - - // Base smootherstep brings dechroma into the midtones while remaining monotonic and C2. - if (dechroma != 0.f) { - purity_scale *= mad(-dechroma, rolloff, 1.f); - } - - // Blend smootherstep squared and cubed for a later, gentler C2 progression. - if (highlight_saturation != 1.f) { - float highlight_rolloff = rolloff * rolloff * mad(HIGHLIGHT_ROLLOFF_CUBIC_BLEND, rolloff, 1.f - HIGHLIGHT_ROLLOFF_CUBIC_BLEND); - purity_scale *= mad(highlight_saturation - 1.f, highlight_rolloff * HIGHLIGHT_PURITY_STRENGTH, 1.f); - } - } - - float3 contrast_input = renodx::tonemap::psychov::psycho25_ApplyAdaptiveMBPurity( - lms_in, - current_adaptive_state_lms, - purity_scale); - float3 contrast_lms = ApplyAnchoredTonalGrading( - contrast_input, - current_adaptive_state_lms, - current_background_state_lms, - cone_response_exponent, - flare, - 1.f, - 1.f, - highlights, - shadows); - - float3 output_lms = CompressPsychoV25ReferenceScaleHull( - contrast_lms, - contrast_input, - current_adaptive_state_lms, - current_background_state_lms, - target_lms_peak, - pre_shoulder_hue_linearity, - post_shoulder_source_hue_recovery_strength, - 1.f, - compression, - peak_value, - renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_BT2020); - return renodx::color::bt709::from::LMS(output_lms); -} - -#endif // RENODX_SHADERS_TONEMAP_PSYCHOV_TEST25_HLSL_ \ No newline at end of file diff --git a/src/games/asscreedblackflagresynced/tonemap/customtest30.hlsli b/src/games/asscreedblackflagresynced/tonemap/customtest30.hlsli new file mode 100644 index 000000000..dc9256843 --- /dev/null +++ b/src/games/asscreedblackflagresynced/tonemap/customtest30.hlsli @@ -0,0 +1,1973 @@ +#ifndef PSYCHOV_CUSTOMTEST30_HLSLI_ +#define PSYCHOV_CUSTOMTEST30_HLSLI_ + +#include "../common.hlsli" + +/* + * Copyright (C) 2026 Carlos Lopez + * SPDX-License-Identifier: MIT + */ + +namespace renodx { +namespace tonemap { +namespace psychov { + +// PsychoV30: selected Mean-A2 / physiological-Yf response +// ========================================================= +// +// Signal contract +// --------------- +// Input and output are direct linear-light BT.709 RGB with D65 white. +// `peak_value` expresses display peak in reference-white-relative units. +// The target volume is the normalized linear BT.709 RGB cube for mode 0 or +// the normalized linear BT.2020 RGB cube for every other mode. Output remains +// represented as linear BT.709 even when the constrained target is BT.2020. +// +// Scientific basis and engineering stages +// --------------------------------------- +// - RGB is transformed to the Stockman/CVRL two-degree LMS basis. +// - The achromatic coordinate is physiological Yf from the +// Stockman-Sharpe LMS-to-XfYfZf transform: +// +// Yf = cL * L + cM * M +// +// Yf is the relative observer coordinate formed by weighted L and M cone +// responses. +// - Purity is direct LMS interpolation toward the adapting neutral while +// retaining the adaptation-relative Yf coordinate. It does not require +// MacLeod-Boynton coordinates or short-wave weighting. +// - CIE 170-2 weighted MacLeod-Boynton chromaticity is isolated to the signed +// fallback's source-boundary continuation. Its metric remains a successor +// candidate for replacement by a coordinate consistent with the A2 path. +// - Adaptation-relative cone ratios are consistent with the early-cone +// background-normalization framework discussed by Stockman and Brainard. +// - The finite endpoint, Mean-A2 direction, and locked-direction target-cube +// projection are the rendering-response and device-mapping stages. +// +// Positive-cone response +// ---------------------- +// Let q_i = LMS_i / anchor_in_i, P_i = peak_value * D65_LMS_i, +// p = contrast * cone_response_exponent, and +// k_i = pow(anchor_out_i / P_i, h). Test30 evaluates: +// +// beta_i = p * h / (1 - k_i) +// e_i = 1 / (1 + (1 / k_i - 1) * pow(q_i, -beta_i)) +// u_i = pow(e_i, 1 / h) +// +// The conceptual response is P_i * u_i. The reciprocal form remains finite +// when the corresponding positive power overflows. It preserves +// anchor_in -> anchor_out, has +// adaptation-point logarithmic slope p, approaches zero as q -> 0, and +// approaches selected peak white as q -> infinity. +// +// Mean-A2 direction +// ----------------- +// A2 denotes this shader's internal orthonormal cone-opponent plane. For +// normalized cone load u: +// +// X = (uL - uM) / sqrt(2) +// C0 = (uL + uM + uS) / sqrt(3) +// Z = (2 * uS - uL - uM) / sqrt(6) +// +// Source A2 direction comes from adaptation-relative q; response A2 direction +// comes from peak-relative post-G u. Normalizing and adding the two directions +// gives their exact angular bisector. Test30 retains the response A2 radius +// and C0, replacing direction only. +// +// Exact target solve +// ------------------ +// With D65 Yf fractions alphaL + alphaM = 1, the normalized physiological +// coordinate represented by (X, C0, Z) is: +// +// A = C0 / sqrt(3) + (alphaL - alphaM) * X / sqrt(2) +// - Z / sqrt(6) +// +// Target RGB is affine in A, X, and Z. Locking the authored A2 direction and +// scaling (X, Z) by s makes all lower/upper RGB-cube planes and the response +// Yf ceiling linear inequalities in (C0, s). The feasible set is a convex +// polygon. Segment projection uses +// +// distance^2 = delta_C0^2 + (X^2 + Z^2) * delta_s^2 +// +// which is exactly Euclidean distance in the original (X, C0, Z) coordinate +// under the locked direction. Full compression analytically finds the nearest +// point inside this fixed-direction model's four-edge feasible polygon. +// +// Cone states containing zero or negative values use the separately documented +// signed linear-A2 fallback with analytic target RGB-cube ray support. +// +// PsychoV research record +// ======================= +// +// This record is carried forward through PsychoV tests so each successor keeps +// the scientific rationale, source attribution, selected implementation, and +// next research directions beside the shader that ships. Test30 extends the +// Test17-Test25 record with Mean-A2 response authoring and an exact +// fixed-direction device-cube projection. +// +// Research objective and system boundary +// -------------------------------------- +// PsychoV studies two coupled systems: +// +// 1. Observer-side organization: receptor coordinates, adaptation-relative +// cone state, achromatic and opponent coordinates, response shaping, and +// visibility/gain mechanisms supported by vision research. +// 2. Device-hull mapping: a joint tone, direction, and target-volume solve +// constrained by display primaries, white, reference-white scale, and peak. +// +// Test30's selected rendering pipeline is: +// +// linear-light BT.709 +// -> Stockman/CVRL LMS +// -> scalar physiological-Yf grading +// -> adaptation-relative LMS purity +// -> adaptation-relative common cone power +// -> anchor-matched finite per-cone G +// -> Mean-A2 direction with post-G radius and C0 +// -> exact fixed-direction target RGB-cube/Yf projection +// -> linear-light BT.709 representation +// +// The caller supplies the current adaptation and desired output-background +// anchors. The runtime signal is reference-white-relative. Absolute retinal +// scale, local/temporal adaptation estimation, visibility thresholds, and +// cortical gain form explicit successor-test research directions below. +// +// 1) Receptor basis and observer coordinates +// ------------------------------------------ +// Brainard's Colorimetry chapter supplies the cone-stage/color-match +// foundation. Stockman and Brainard build on that receptor basis for +// first-site and second-site adaptation. Test30 transforms linear-light +// BT.709 through XYZ to the Stockman/CVRL two-degree LMS fit. +// +// Sources: +// https://color2.psych.upenn.edu/brainard/papers/Brainard_Stockman_Colorimetry.pdf +// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf +// +// The published Stockman-Sharpe fundamentals include standard prereceptoral +// lens and macular filtering for an average, mainly foveal two-degree observer. +// CVRL documents the ocular-media and macular-pigment filters, their strong +// short-wavelength absorption, and their observer variation. Successor tests +// can expose age, field size, eccentricity, lens, and macular assumptions when +// personalized observer transforms become an input. +// +// Sources: +// http://www.cvrl.org/background.htm +// http://www.cvrl.org/database/text/intros/intromaclens.htm +// +// Test30's selected positive-cone path carries physiological Yf: +// +// physiological Yf = cL * L + cM * M +// +// where cL and cM come directly from the Yf row of the base +// Stockman-Sharpe LMS-to-XfYfZf transform. The selected purity and response +// stages operate directly in LMS and Yf and do not use an S-cone weight. +// +// The signed fallback separately retains CIE 170-2 weighted +// MacLeod-Boynton chromaticity for source-boundary continuation: +// +// l = Lw / (Lw + Mw) +// s = Sw / (Lw + Mw) +// +// MacLeod-Boynton (1979) supplies the classic weighted-cone chromaticity +// construction. CVRL/CIE physiological data and repository constants supply +// the exact coefficients used here. Psychtoolbox documents a practical +// CIE-based LMS-to-MacLeod-Boynton implementation. Webster and Leonard use a +// modified MB framework for adaptation norms. Mantiuk et al. describe a +// practical LMS scaling whose L+M sum carries an achromatic coordinate. +// +// Sources: +// http://www.cvrl.org/ciexyzpr.htm +// https://psychtoolbox.org/docs/LMSToMacBoyn +// MacLeod & Boynton, JOSA 1979, doi:10.1364/JOSA.69.001183 +// Webster & Leonard, JOSA A 2008, doi:10.1364/JOSAA.25.002817 +// https://pmc.ncbi.nlm.nih.gov/articles/PMC2657039/ +// https://www.cl.cam.ac.uk/~rkm38/pdfs/mantiuk2020practical_csf.pdf +// +// 2) Early cone adaptation +// ------------------------ +// Stockman and Brainard express first-site L-cone contrast as +// +// C_L = delta_L / (L_b + L_0) +// +// with corresponding M- and S-cone forms. Equivalently, the background sets +// the cone gain: +// +// g_L = 1 / (L_b + L_0) +// g_L * (L - L_b) = delta_L / (L_b + L_0) +// +// Test30 receives caller-authored adaptation LMS as `anchor_in` and uses +// q_i = LMS_i / anchor_in_i as its static background-relative state. This +// preserves the architecture of cone-specific normalization while keeping +// adaptation policy in the caller. A successor with image/retinal context can +// estimate L_b, M_b, S_b and semi-saturation L_0, M_0, S_0 over space and time. +// +// Stockman et al. describe first-site regulation across light levels and the +// transition toward bleaching-supported high-light sensitivity regulation. +// Source: JOV 2006, doi:10.1167/6.11.5. +// +// Webster and Leonard distinguish a response norm, the adapting level that +// leaves white judgments unbiased, from a perceptual norm, the stimulus that +// appears white. Their experiments found close tracking between these norms. +// PsychoV uses adapted-background reference for the directly carried cone +// state and retains response/perceptual norms as higher-level interpretations +// of the current neutral coding state. +// Source: JOSA A 2008, doi:10.1364/JOSAA.25.002817. +// +// CVRL documents observing-condition and chromatic-adaptation dependence in +// physiological luminosity functions, while cone spectral sensitivities stay +// stable through ordinary adaptation levels. This supports carrying Yf with +// the current adapted observer state. +// Source: http://www.cvrl.org/database/text/intros/introvl.htm +// +// 2a) Dim cone-noise extension +// ---------------------------- +// Cone-mediated detection reaches a quantal/transduction-noise regime before +// rod-dominated vision. Approximate De Vries-Rose behavior gives threshold +// cone contrast a log-log slope near -0.5 against retinal illuminance. Higher +// adaptation levels approach Weber-like behavior, where threshold contrast is +// approximately constant relative to background. A calibrated successor can +// use retinal illuminance and cone-specific noise to attenuate scene +// differences below this visibility floor before postreceptoral processing. +// +// Stockman and Brainard discuss the range where cone-contrast coordinates +// approach Weber behavior. Angueyra and Rieke measure primate-cone +// phototransduction noise and its contribution to the dim-light threshold. +// Sources: +// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf +// Angueyra & Rieke, Nature Neuroscience 2013, doi:10.1038/nn.3534 +// https://pmc.ncbi.nlm.nih.gov/articles/PMC3815624/ +// +// 2b) High-light bleaching extension +// ----------------------------------- +// A retinal-illuminance-calibrated successor can represent steady-state cone +// pigment availability with the Rushton-Henry form +// +// p_available(I) = 1 / (1 + I / I0) +// +// and the complementary bleached fraction +// +// p_bleached(I) = I / (I + I0), I0 approximately 10^4.3 Td. +// +// Physiological placement follows adaptation-state definition and precedes +// postreceptoral opponent response and pooled gain. A rendering realization +// can apply availability to cone excursions around the adapted-white anchor, +// approaching equal white at the carried achromatic level as availability +// approaches zero. Test30's selected highlight endpoint is the finite-G +// equation documented above; the bleaching equations remain a calibrated +// successor path tied to retinal units. +// +// Sources: +// Stockman et al., JOV 2006, doi:10.1167/6.11.5 +// Stockman et al., JOV 2018, doi:10.1167/18.6.12 +// Rushton & Henry, Vision Research 1968, +// doi:10.1016/0042-6989(68)90040-0 +// http://www.cvrl.org/database/text/intros/introbleaches.htm +// +// 3) Background-normalized opponent organization +// ------------------------------------------------ +// Test30 applies adaptation-relative purity directly in LMS, then constructs +// A2 as an orthonormal decomposition of the three adaptation/peak-normalized +// cone loads. A2 supplies an exact Euclidean metric and sixfold cone-axis +// geometry for Mean-A2 direction authoring and target projection. The signed +// fallback still uses weighted MacLeod-Boynton chromaticity for one +// source-boundary trace; this is not part of the selected positive path and +// should be revisited alongside a fitted ACC/DKL or A2-consistent fallback. +// +// 4) Saturating response research +// ------------------------------- +// Michaelis-Menten/Naka-Rushton response families provide receptor and +// early-cortical contrast models; supersaturating forms capture additional +// cortical response shapes. Peirce analyzes how saturating and supersaturating +// contrast response functions affect visual-cortex interpretation. +// Source: Peirce, JOV 2007, doi:10.1167/7.6.13. +// +// Test30 selects the anchor-preserving finite per-cone G above. Its reciprocal +// parameterization fixes the caller's input/output anchor, logarithmic slope, +// and selected peak endpoint. This creates a controlled rendering response for +// direct comparison with future fitted receptor or cortical response models. +// +// 5) ON/OFF response research +// --------------------------- +// Retinal ON and OFF channels separate increments and decrements around an +// adapted background. Schiller reviews their parallel visual-system roles. +// Yu, Turner, Baudin, and Rieke show that cone adaptation and downstream +// nonlinearities can combine unexpectedly for natural-image structure, +// motivating natural-image validation of any explicit polarity split. +// +// Rahimi-Nasrabadi et al. validate an ONOFF image algorithm on calibrated +// grayscale images and propose color extension through a scalar lightness +// dimension. PsychoV's scalar-Yf highlight/shadow grade follows the analogous +// engineering principle of applying polarity-shaped grades to one achromatic +// coordinate while retaining cone ratios. +// +// Sources: +// Schiller, Trends Neurosci 1992, +// doi:10.1016/0166-2236(92)90017-3 +// Yu et al., eLife 2022, doi:10.7554/eLife.70611 +// Rahimi-Nasrabadi et al., Cell Reports 2021, +// doi:10.1016/j.celrep.2021.108692 +// +// Test30's automatic finite-G curve uses a centered static log-range prior. +// A successor ON/OFF stage can fit separate increment/decrement responses and +// preserve the same adaptation anchor and device-hull coupling. +// +// 6) Pooled divisive gain research +// -------------------------------- +// Divisive normalization models pooled neural response as a channel drive +// divided by a semi-saturated measure of neighboring/population activity. +// This supplies a research path for coupled achromatic/opponent energy, +// spatial context, and contrast-dependent gain after polarity processing. +// +// Sources: +// Heeger, Visual Neuroscience 1992, +// doi:10.1017/S0952523800009640 +// Carandini & Heeger, Nature Reviews Neuroscience 2012, +// doi:10.1038/nrn3136 +// Bun & Horwitz, Color Research & Application 2023, +// doi:10.1002/col.22903 +// +// A successor implementation can add fitted pooling neighborhoods and +// semi-saturation constants after a selected opponent/ON-OFF stage. Test30 +// supplies a static per-pixel response baseline for that comparison. +// +// 7) Unified device-hull tone and gamut mapping +// --------------------------------------------- +// Display mapping is constrained by the complete target RGB volume. In +// normalized target coordinates this is +// +// 0 <= R,G,B <= 1. +// +// Lower and upper channel planes, faces, edges, corners, and neutral-axis +// capacity participate in one device-hull problem. High-purity directions can +// reach a target face at a lower achromatic level than D65, so a joint solve +// trades radial opponent distance and achromatic coordinate according to the +// selected metric. ITU-R BT.2408 supplies the practical HDR Reference White +// framing that keeps reference/diffuse white distinct from display peak. +// Source: https://www.itu.int/pub/R-REP-BT.2408 +// +// Test30 fixes the Mean-A2 authored direction and projects exactly in the full +// orthonormal (X,C0,Z) metric over the resulting convex target-cube/Yf polygon. +// This extends Test25's numerical ray support into an analytic nearest-point +// solve for the selected direction. BT.709 and BT.2020 modes share the same +// D65 cone normalization and use their respective complete RGB cubes. +// +// A successor sectional solve can search multiple directions within the +// active cone-axis sextant, include a fitted postreceptoral metric, and compare +// face/edge/interior candidates. Mean-A2 remains the preferred authored +// trajectory candidate and Test30 remains the exact fixed-direction baseline. +// +// 7a) Hue-objective research inside the hull solve +// ------------------------------------------------ +// Mizokami et al. and O'Neil et al. study a functional account of the Abney +// effect based on an equivalent Gaussian spectral peak. For short and medium +// wavelengths, the equivalent-peak parameter can provide a hue objective as +// purity changes. A future spectral precomputation can map weighted-LMS/MB +// chromaticity to mu_eq and evaluate mu_eq alongside A2/ACC direction during +// target-hull optimization while carrying Yf separately. +// +// Sources: +// Mizokami et al., JOV 2006, doi:10.1167/6.9.12 +// O'Neil et al., JOSA A 2012, doi:10.1364/JOSAA.29.00A165 +// +// 7b) Simultaneous-range auto-compression +// --------------------------------------- +// `compression == 0` uses a static centered simultaneous-range reference: +// +// side_range = reference_range_log10 / 2 +// h = max(side_range / log10(peak_Yf / anchor_Yf), 1) +// +// Kunkel and Reinhard report approximately 3.7 log10 units under their adapted +// test conditions. Jiang and Fairchild directly measured bright/dark +// simultaneous range on an Apple Pro Display XDR: approximately 3.3 log10 for +// the average observer and 3.47 for one observer at 1600 cd/m^2 with a +// 3.4-degree stimulus. Their fitted maxima were approximately 3.24 at +// 452 cd/m^2 and 3.40 at 1600 cd/m^2. These condition-dependent measurements +// motivate future display-, surround-, field-size-, and glare-aware range +// selection. Test30 keeps 3.7 as its static baseline for direct continuity +// with Test22-Test25. +// +// Sources: +// Kunkel & Reinhard, APGV 2010, doi:10.1145/1836248.1836251 +// Jiang & Fairchild, JIST 2021, +// doi:10.2352/J.ImagingSci.Technol.2021.65.5.050401 +// +static const float PSYCHO30_EPSILON = 1e-6f; +static const float PSYCHO30_EPSILON2 = PSYCHO30_EPSILON * PSYCHO30_EPSILON; +static const float PSYCHO30_MAX_FINITE_INPUT = 65504.f; +static const float PSYCHO30_AUTO_COMPRESSION_SENTINEL = 0.f; +static const float PSYCHO30_LARGE_SUPPORT = 1e20f; +// Kunkel/Reinhard report approximately 3.7 log10 units under their adapted +// simultaneous-range test conditions. Test30 treats half that total range as +// the range above adaptation and half as the range below adaptation. +// Jiang/Fairchild report stimulus- and display-dependent simultaneous values. +static const float PSYCHO30_REFERENCE_SIMULTANEOUS_RANGE_LOG10 = 3.7f; +static const float PSYCHO30_HIGHLIGHT_GRADE_REFERENCE_WHITE = 1.f; +static const float PSYCHO30_SHADOW_GRADE_RANGE_STOPS = 4.f; + +static const float3x3 PSYCHO30_BT709_TO_LMS_MAT = mul( + renodx::color::STOCKMAN_CVRL_XYZ_TO_LMS_2DEG_FIT, + renodx::color::BT709_TO_XYZ_MAT); +static const float3x3 PSYCHO30_LMS_TO_BT709_MAT = mul( + renodx::color::XYZ_TO_BT709_MAT, + renodx::color::STOCKMAN_CVRL_LMS_TO_XYZ_2DEG_FIT); +static const float3x3 PSYCHO30_LMS_TO_BT2020_MAT = mul( + renodx::color::XYZ_TO_BT2020_MAT, + renodx::color::STOCKMAN_CVRL_LMS_TO_XYZ_2DEG_FIT); + +static const float3 PSYCHO30_SOURCE_YF_COEFFICIENTS = mul( + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1], + PSYCHO30_BT709_TO_LMS_MAT); +static const float3 PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS = max( + PSYCHO30_SOURCE_YF_COEFFICIENTS, + float3(0.f, 0.f, 0.f)); +static const float3 PSYCHO30_SOURCE_YF_WEIGHTS = + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS + / max( + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS.x + + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS.y + + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS.z, + PSYCHO30_EPSILON); + +// BT.709 and BT.2020 share D65. These alpha values partition normalized Yf +// between the L and M cone loads and sum to one. +static const float3 PSYCHO30_D65_WHITE_LMS = mul( + PSYCHO30_BT709_TO_LMS_MAT, + float3(1.f, 1.f, 1.f)); +static const float PSYCHO30_D65_WHITE_YF = dot( + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1], + PSYCHO30_D65_WHITE_LMS); +static const float PSYCHO30_D65_ALPHA_L = + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][0] + * PSYCHO30_D65_WHITE_LMS.x + / PSYCHO30_D65_WHITE_YF; +static const float PSYCHO30_D65_ALPHA_M = + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][1] + * PSYCHO30_D65_WHITE_LMS.y + / PSYCHO30_D65_WHITE_YF; +static const float PSYCHO30_D65_ALPHA_DELTA = + PSYCHO30_D65_ALPHA_L - PSYCHO30_D65_ALPHA_M; +// Direct target basis at fixed normalized physiological coordinate A: +// +// target_rgb = A + X * A2_X_RGB + Z * A2_Z_RGB +// +// These are the symbolic inverse orthonormal-cone transform followed by the +// selected LMS-to-RGB matrix; they avoid reconstructing LMS per pixel. +static const float3 PSYCHO30_BT709_A2_X_RGB = mul( + PSYCHO30_LMS_TO_BT709_MAT, + float3( + sqrt(2.f) * PSYCHO30_D65_ALPHA_M + * PSYCHO30_D65_WHITE_LMS.x, + -sqrt(2.f) * PSYCHO30_D65_ALPHA_L + * PSYCHO30_D65_WHITE_LMS.y, + rsqrt(2.f) + * (PSYCHO30_D65_ALPHA_M + - PSYCHO30_D65_ALPHA_L) + * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_BT709_A2_Z_RGB = mul( + PSYCHO30_LMS_TO_BT709_MAT, + float3( + 0.f, + 0.f, + sqrt(6.f) * 0.5f * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_BT2020_A2_X_RGB = mul( + PSYCHO30_LMS_TO_BT2020_MAT, + float3( + sqrt(2.f) * PSYCHO30_D65_ALPHA_M + * PSYCHO30_D65_WHITE_LMS.x, + -sqrt(2.f) * PSYCHO30_D65_ALPHA_L + * PSYCHO30_D65_WHITE_LMS.y, + rsqrt(2.f) + * (PSYCHO30_D65_ALPHA_M + - PSYCHO30_D65_ALPHA_L) + * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_BT2020_A2_Z_RGB = mul( + PSYCHO30_LMS_TO_BT2020_MAT, + float3( + 0.f, + 0.f, + sqrt(6.f) * 0.5f * PSYCHO30_D65_WHITE_LMS.z)); + +// Anchor-preserving, slope-normalized finite endpoint. In scalar form, with +// q=x/anchor, k=(anchor/peak)^h, beta=h/(1-k): +// +// F(x) = peak * [1 + (1/k - 1) * q^(-beta)]^(-1/h) +// +// Thus F(anchor)=anchor, dF/dx at the anchor is one, F(0)=0, and the positive +// asymptote is `peak`. MeanA2Response fuses the common cone power into beta. +float psycho30_FiniteEndpoint( + float x, + float anchor, + float peak, + float h) { + bool uniform_response = h == 1.f; + float anchor_power = uniform_response + ? anchor / peak + : pow(anchor / peak, h); + anchor_power = max(anchor_power, 1e-37f); + float slope_normalization = max(1.f - anchor_power, PSYCHO30_EPSILON); + float normalized_input = max(x / anchor, 0.f); + if (!(normalized_input > 0.f)) return 0.f; + + float encoded = rcp( + 1.f + + (rcp(anchor_power) - 1.f) + * pow(normalized_input, -h / slope_normalization)); + return peak + * (uniform_response + ? encoded + : pow(max(encoded, 0.f), rcp(h))); +} + +// Automatic h centers the chosen simultaneous log10 range around adaptation: +// +// h = max((reference_range / 2) / log10(peak_yf / anchor_yf), 1) +// +// Manual positive h is passed through unchanged by the public entry point. +float psycho30_AutoCompressionPower(float anchor_yf, float peak_yf) { + float above_adaptation_range = log10(peak_yf / anchor_yf); + return max( + (PSYCHO30_REFERENCE_SIMULTANEOUS_RANGE_LOG10 * 0.5f) + / above_adaptation_range, + 1.f); +} + +// Preserve positive source-total bookkeeping while retaining the source RGB +// direction as far as its first lower RGB-cube boundary. This keeps finite +// signed/wide-gamut inputs defined by one direction-preserving boundary trace. +float3 psycho30_AnchorSourcePositiveTotalToYf(float3 source_rgb) { + float source_total = dot( + max(source_rgb, float3(0.f, 0.f, 0.f)), + PSYCHO30_SOURCE_YF_WEIGHTS); + if (!(source_total > PSYCHO30_EPSILON) + || isnan(source_total) + || isinf(source_total)) { + return float3(0.f, 0.f, 0.f); + } + + [branch] + if (all(source_rgb >= float3(0.f, 0.f, 0.f))) { + return mul(PSYCHO30_BT709_TO_LMS_MAT, source_rgb); + } + + float3 residual = source_rgb - source_total; + float3 lower_fraction = renodx::math::Select( + residual < float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON), + source_total / max(-residual, float3(PSYCHO30_EPSILON, PSYCHO30_EPSILON, PSYCHO30_EPSILON)), + float3( + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT)); + float boundary_fraction = min(1.f, renodx::math::Min(lower_fraction)); + float3 bounded_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + source_total + residual * boundary_fraction); + float bounded_yf = renodx::color::yf::from::LMS(bounded_lms); + return bounded_yf > PSYCHO30_EPSILON + && !isnan(bounded_yf) + && !isinf(bounded_yf) + ? bounded_lms + * (source_total * PSYCHO30_D65_WHITE_YF / bounded_yf) + : PSYCHO30_D65_WHITE_LMS * source_total; +} + +float psycho30_GradeQuinticUnitRamp(float t) { + t = saturate(t); + return t * t * t * (t * (t * 6.f - 15.f) + 10.f); +} + +float psycho30_HighlightsScalar( + float x, + float highlights, + float adapted_anchor_yf) { + if (highlights == 1.f) return x; + + float t = 0.f; + if (x > adapted_anchor_yf) { + t = saturate( + log2(x / adapted_anchor_yf) + / log2( + PSYCHO30_HIGHLIGHT_GRADE_REFERENCE_WHITE + / adapted_anchor_yf)); + } + t = psycho30_GradeQuinticUnitRamp(t); + + float ratio = max( + x / adapted_anchor_yf, + PSYCHO30_EPSILON); + if (highlights > 1.f) { + return lerp( + x, + adapted_anchor_yf * pow(ratio, highlights), + t); + } + + float compressed = adapted_anchor_yf * pow(ratio, 2.f - highlights); + return renodx::math::DivideSafe( + x * x, + lerp(x, compressed, t), + x); +} + +float psycho30_ShadowsScalar( + float x, + float shadows, + float adapted_anchor_yf) { + if (shadows == 1.f) return x; + + float ratio = max(x / adapted_anchor_yf, 0.f); + float base_term = x * adapted_anchor_yf; + float base_scale = renodx::math::DivideSafe(base_term, ratio, 0.f); + float shadow_floor = adapted_anchor_yf + * exp2(-PSYCHO30_SHADOW_GRADE_RANGE_STOPS); + float t = x > shadow_floor + ? saturate( + log2(x / adapted_anchor_yf) + / log2(shadow_floor / adapted_anchor_yf)) + : 1.f; + t = psycho30_GradeQuinticUnitRamp(t); + + if (shadows > 1.f) { + float raised = x * (1.f + renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO30_EPSILON), shadows), 0.f)); + return x + (raised - x * (1.f + base_scale)) * t; + } + + float lowered = x * (1.f - renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO30_EPSILON), 2.f - shadows), 0.f)); + return x + (lowered - x * (1.f - base_scale)) * t; +} + +// Direct LMS interpolation toward the adapting neutral at fixed +// adaptation-relative physiological Yf. The selected purity path does not +// require MacLeod-Boynton coordinates or an S-cone weight. +float3 psycho30_ApplyAdaptiveLMSPurity( + float3 input_lms, + float3 adaptive_lms, + float purity_delta) { + if (abs(purity_delta - 1.f) <= 1e-5f) return input_lms; + + float relative_yf = max( + renodx::color::yf::from::LMS(input_lms / adaptive_lms), + 0.f); + if (!(relative_yf > 0.f)) return float3(0.f, 0.f, 0.f); + + float neutral_scale = relative_yf + / renodx::color::yf::from::LMS( + float3(1.f, 1.f, 1.f)); + return lerp( + adaptive_lms * neutral_scale, + input_lms, + purity_delta); +} + +// Signed-fallback MacLeod-Boynton coordinate helpers. The selected positive +// path does not call this block. +float2 psycho30_AdaptiveNeutralMB() { + float lm_weight_sum = + renodx::color::CIE1702_MB_CIE_WEIGHTS.x + + renodx::color::CIE1702_MB_CIE_WEIGHTS.y; + return float2( + renodx::color::CIE1702_MB_CIE_WEIGHTS.x, + renodx::color::CIE1702_MB_CIE_WEIGHTS.z) + / lm_weight_sum; +} + +float3 psycho30_LMSFromYfOpponent( + float yf, + float rg, + float bv, + float3 anchor_lms) { + float2 neutral_mb = psycho30_AdaptiveNeutralMB(); + float lm_anchor_mix = mad( + anchor_lms.x, + neutral_mb.x, + anchor_lms.y * (1.f - neutral_mb.x)); + float denominator = + (yf - (anchor_lms.x - anchor_lms.y) * rg) + / lm_anchor_mix; + float3 relative_weighted = float3( + neutral_mb.x * denominator + rg, + (1.f - neutral_mb.x) * denominator - rg, + neutral_mb.y * denominator + bv); + return relative_weighted * anchor_lms + / renodx::color::CIE1702_MB_CIE_WEIGHTS; +} + +float3 psycho30_LMSFromPhysicalYfMB( + float yf, + float2 mb, + float3 anchor_lms) { + float2 neutral_mb = psycho30_AdaptiveNeutralMB(); + float2 offset = mb - neutral_mb; + float lm_anchor_mix = mad( + anchor_lms.x, + neutral_mb.x, + anchor_lms.y * (1.f - neutral_mb.x)); + float anchor_delta = anchor_lms.x - anchor_lms.y; + float relative_denominator = renodx::math::DivideSafe( + yf, + lm_anchor_mix + anchor_delta * offset.x, + 0.f); + return psycho30_LMSFromYfOpponent( + yf, + relative_denominator * offset.x, + relative_denominator * offset.y, + anchor_lms); +} + +float3 psycho30_TargetRGBFromLMS( + float3 lms, + int target_gamut_mode) { + float3 target_rgb; + [branch] + if (target_gamut_mode == 0) { + target_rgb = mul(PSYCHO30_LMS_TO_BT709_MAT, lms); + } else { + target_rgb = mul(PSYCHO30_LMS_TO_BT2020_MAT, lms); + } + return target_rgb; +} + +float psycho30_TargetNeutralYfLimit( + float target_rgb_peak, + float3 anchor_lms, + int target_gamut_mode) { + float anchor_yf = renodx::color::yf::from::LMS(anchor_lms); + if (!(anchor_yf > PSYCHO30_EPSILON)) return 0.f; + float3 rgb_per_yf = psycho30_TargetRGBFromLMS( + anchor_lms, + target_gamut_mode) + / anchor_yf; + float max_rgb_per_yf = renodx::math::Max(rgb_per_yf); + return all(rgb_per_yf >= float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON)) + && max_rgb_per_yf > PSYCHO30_EPSILON + ? target_rgb_peak / max_rgb_per_yf + : 0.f; +} + +// Weighted MacLeod-Boynton chromaticity is retained only for the signed +// fallback's source-boundary continuation. +float2 psycho30_MBFromRelativeLMS( + float3 relative_lms, + out uint valid) { + const float3 weights = renodx::color::CIE1702_MB_CIE_WEIGHTS; + float yf = renodx::color::yf::from::LMS(relative_lms); + valid = yf > PSYCHO30_EPSILON + && !isnan(yf) + && !isinf(yf) + && !any(isnan(relative_lms)) + && !any(isinf(relative_lms)) + ? 1u + : 0u; + if (valid == 0u) { + return psycho30_AdaptiveNeutralMB(); + } + float inverse_yf = rcp(yf); + return float2( + relative_lms.x * weights.x * inverse_yf, + relative_lms.z * weights.z * inverse_yf); +} + +float3 psycho30_ApplySignedConeResponseFallback( + float3 source_relative_lms, + float response_power) { + if (abs(response_power - 1.f) <= PSYCHO30_EPSILON) { + return source_relative_lms; + } + return sign(source_relative_lms) + * pow( + abs(source_relative_lms), + float3(response_power, response_power, response_power)); +} + +// Build the selected response coordinate directly from normalized response u: +// source q authors one A2 direction, finite-G u authors the other direction +// and supplies radius, C0, and normalized physiological Yf. Equal normalized +// direction weights form the exact angular midpoint when both are defined. +float3 psycho30_MeanA2Response( + float3 input_lms, + float3 anchor_in_lms, + float3 anchor_out_lms, + float3 peak_lms, + float response_power, + float response_h, + out float response_yf, + out uint valid) { + float3 source_q = input_lms / anchor_in_lms; + valid = all(source_q > float3(0.f, 0.f, 0.f)) ? 1u : 0u; + if (valid == 0u) { + response_yf = 0.f; + return float3(0.f, 0.f, 0.f); + } + + bool uniform_response = response_h == 1.f; + float3 anchor_power; + [branch] + if (uniform_response) { + anchor_power = anchor_out_lms / peak_lms; + } else { + anchor_power = pow( + anchor_out_lms / peak_lms, + float3(response_h, response_h, response_h)); + } + anchor_power = max( + anchor_power, + float3(1e-37f, 1e-37f, 1e-37f)); + float3 slope_normalization = max( + float3(1.f, 1.f, 1.f) - anchor_power, + float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON)); + float3 input_exponent = response_power * response_h / slope_normalization; + float3 encoded = rcp( + float3(1.f, 1.f, 1.f) + + (rcp(anchor_power) - float3(1.f, 1.f, 1.f)) + * pow(source_q, -input_exponent)); + float3 response_u; + [branch] + if (uniform_response) { + response_u = encoded; + } else { + float inverse_response_h = rcp(response_h); + response_u = pow( + max(encoded, float3(0.f, 0.f, 0.f)), + float3( + inverse_response_h, + inverse_response_h, + inverse_response_h)); + } + float2 source_a2 = float2( + (source_q.x - source_q.y) * rsqrt(2.f), + (2.f * source_q.z - source_q.x - source_q.y) + * rsqrt(6.f)); + float2 response_a2 = float2( + (response_u.x - response_u.y) * rsqrt(2.f), + (2.f * response_u.z - response_u.x - response_u.y) + * rsqrt(6.f)); + float2 authored_a2 = response_a2; + float source_radius2 = dot(source_a2, source_a2); + float response_radius2 = dot(response_a2, response_a2); + + if (source_radius2 > PSYCHO30_EPSILON2 + && response_radius2 > PSYCHO30_EPSILON2) { + float inverse_source_radius = rsqrt(source_radius2); + float inverse_response_radius = rsqrt(response_radius2); + float response_radius = response_radius2 * inverse_response_radius; + float2 mean_direction = source_a2 * inverse_source_radius + + response_a2 * inverse_response_radius; + float mean_radius2 = dot(mean_direction, mean_direction); + if (mean_radius2 > PSYCHO30_EPSILON2) { + authored_a2 = mean_direction + * rsqrt(mean_radius2) + * response_radius; + } + } + + response_yf = PSYCHO30_D65_ALPHA_L * response_u.x + + PSYCHO30_D65_ALPHA_M * response_u.y; + float3 desired_ortho = float3( + authored_a2.x, + (response_u.x + response_u.y + response_u.z) * rsqrt(3.f), + authored_a2.y); + return desired_ortho; +} + +float2 psycho30_ClosestPointOnScaleSegment( + float desired_c0, + float desired_rho2, + float2 segment_start, + float2 segment_end) { + float2 segment = segment_end - segment_start; + float denominator = segment.x * segment.x + + desired_rho2 * segment.y * segment.y; + if (!(denominator > PSYCHO30_EPSILON2)) return segment_start; + float numerator = (desired_c0 - segment_start.x) * segment.x + + desired_rho2 * (1.f - segment_start.y) * segment.y; + float t = saturate(numerator / denominator); + return segment_start + segment * t; +} + +// Exact nearest point for the fixed authored A2 direction. The RGB cube and +// A<=response_yf ceiling become a four-edge convex polygon in (C0, radial +// scale). `desired_rho2` in the segment metric preserves ordinary Euclidean +// distance in (X,C0,Z). +// For radial target RGB r, n=max(-r), and p=max(r), feasibility is exactly: +// +// scale * n <= A <= 1 - scale * p +// 0 <= A <= min(response_yf, 1) +float3 psycho30_YfCeilingSolve( + float3 desired_coord, + float response_yf, + int target_gamut_mode, + out uint valid) { + valid = !any(isnan(desired_coord)) + && !any(isinf(desired_coord)) + && !isnan(response_yf) + && !isinf(response_yf) + ? 1u + : 0u; + if (valid == 0u) return float3(0.f, 0.f, 0.f); + + float max_a = saturate(response_yf); + float radial_yf = PSYCHO30_D65_ALPHA_DELTA + * desired_coord.x * rsqrt(2.f) + - desired_coord.z * rsqrt(6.f); + float desired_a = desired_coord.y * rsqrt(3.f) + radial_yf; + float3 radial_rgb; + [branch] + if (target_gamut_mode == 0) { + radial_rgb = desired_coord.x * PSYCHO30_BT709_A2_X_RGB + + desired_coord.z * PSYCHO30_BT709_A2_Z_RGB; + } else { + radial_rgb = desired_coord.x * PSYCHO30_BT2020_A2_X_RGB + + desired_coord.z * PSYCHO30_BT2020_A2_Z_RGB; + } + float3 desired_target_rgb = desired_a + radial_rgb; + if (desired_a >= 0.f + && desired_a <= max_a + && all(desired_target_rgb >= float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON)) + && all(desired_target_rgb <= float3( + 1.f + PSYCHO30_EPSILON, + 1.f + PSYCHO30_EPSILON, + 1.f + PSYCHO30_EPSILON))) { + return desired_coord; + } + + float desired_rho2 = dot(desired_coord.xz, desired_coord.xz); + if (!(desired_rho2 > PSYCHO30_EPSILON2)) { + return float3( + 0.f, + clamp(desired_coord.y, 0.f, sqrt(3.f) * max_a), + 0.f); + } + + float positive_pressure = renodx::math::Max(radial_rgb); + float negative_pressure = -renodx::math::Min(radial_rgb); + if (!(positive_pressure > 0.f) + || !(negative_pressure > 0.f)) { + valid = 0u; + return float3(0.f, 0.f, 0.f); + } + + float inverse_positive = rcp(positive_pressure); + float inverse_negative = rcp(negative_pressure); + float inverse_pressure_sum = rcp( + positive_pressure + negative_pressure); + float apex_a = negative_pressure * inverse_pressure_sum; + float upper_a = min(max_a, apex_a); + float max_a_scale = min( + max_a * inverse_negative, + (1.f - max_a) * inverse_positive); + float upper_scale = min( + max_a * inverse_negative, + inverse_pressure_sum); + float2 vertex0 = float2(0.f, 0.f); + float2 vertex1 = float2(sqrt(3.f) * max_a, 0.f); + float2 vertex2 = float2( + sqrt(3.f) * (max_a - radial_yf * max_a_scale), + max_a_scale); + float2 vertex3 = float2( + sqrt(3.f) * (upper_a - radial_yf * upper_scale), + upper_scale); + float2 best_c0_scale = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex0, + vertex1); + float2 best_delta = best_c0_scale - float2(desired_coord.y, 1.f); + float best_cost = best_delta.x * best_delta.x + + desired_rho2 * best_delta.y * best_delta.y; + + float2 candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex1, + vertex2); + float2 candidate_delta = candidate - float2(desired_coord.y, 1.f); + float candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + best_cost = candidate_cost; + } + + candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex2, + vertex3); + candidate_delta = candidate - float2(desired_coord.y, 1.f); + candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + best_cost = candidate_cost; + } + + candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex0, + vertex3); + candidate_delta = candidate - float2(desired_coord.y, 1.f); + candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + } + + float3 solved_coord = float3( + desired_coord.x * max(best_c0_scale.y, 0.f), + best_c0_scale.x, + desired_coord.z * max(best_c0_scale.y, 0.f)); + valid = !any(isnan(solved_coord)) && !any(isinf(solved_coord)) ? 1u : 0u; + return valid != 0u ? solved_coord : float3(0.f, 0.f, 0.f); +} + +float2 psycho30_LinearA2Opponent( + float3 lms, + float3 anchor_lms) { + float3 q = lms / anchor_lms; + return float2( + (q.x - q.y) * rsqrt(2.f), + (2.f * q.z - q.x - q.y) * rsqrt(6.f)); +} + +float3 psycho30_LMSFromLinearA2Opponent( + float2 opponent, + float physical_yf, + float3 anchor_lms) { + float difference = sqrt(2.f) * opponent.x; + float a_l = renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][0] + * anchor_lms.x; + float a_m = renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][1] + * anchor_lms.y; + float q_m = (physical_yf - a_l * difference) / (a_l + a_m); + float q_l = q_m + difference; + float q_s = 0.5f * (sqrt(6.f) * opponent.y + q_l + q_m); + return float3(q_l, q_m, q_s) * anchor_lms; +} + +float psycho30_LinearA2TargetSupport( + float2 direction, + float clip_magnitude, + float physical_yf, + float3 anchor_lms, + int target_gamut_mode, + float target_rgb_peak) { + if (!(clip_magnitude > PSYCHO30_EPSILON)) return 0.f; + + float3 neutral_target = psycho30_TargetRGBFromLMS( + psycho30_LMSFromLinearA2Opponent( + float2(0.f, 0.f), + physical_yf, + anchor_lms), + target_gamut_mode); + if (any(isnan(neutral_target)) + || any(isinf(neutral_target)) + || any(neutral_target < float3(0.f, 0.f, 0.f)) + || any(neutral_target > float3( + target_rgb_peak, + target_rgb_peak, + target_rgb_peak))) { + return 0.f; + } + + float3 unit_target = psycho30_TargetRGBFromLMS( + psycho30_LMSFromLinearA2Opponent( + direction, + physical_yf, + anchor_lms), + target_gamut_mode); + float3 delta_target = unit_target - neutral_target; + if (any(isnan(delta_target)) || any(isinf(delta_target))) return 0.f; + + float3 upper_support = renodx::math::Select( + delta_target > float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON), + (float3(target_rgb_peak, target_rgb_peak, target_rgb_peak) - neutral_target) + / max( + delta_target, + float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON)), + float3( + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT)); + float3 lower_support = renodx::math::Select( + delta_target < float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON), + neutral_target + / max( + -delta_target, + float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON)), + float3( + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT)); + return max( + min( + clip_magnitude, + min( + renodx::math::Min(upper_support), + renodx::math::Min(lower_support))), + 0.f); +} + +float psycho30_Cross2(float2 a, float2 b) { + return a.x * b.y - a.y * b.x; +} + +float psycho30_RaySegmentRadius( + float2 origin, + float2 direction, + float2 a, + float2 b) { + float2 edge = b - a; + float denominator = psycho30_Cross2(direction, edge); + if (abs(denominator) <= PSYCHO30_EPSILON) return PSYCHO30_LARGE_SUPPORT; + float2 ao = a - origin; + float t = psycho30_Cross2(ao, edge) / denominator; + float u = psycho30_Cross2(ao, direction) / denominator; + return t >= 0.f && u >= 0.f && u <= 1.f + ? t + : PSYCHO30_LARGE_SUPPORT; +} + +float psycho30_TransformedSourceClipLinearA2Magnitude( + float2 source_mb, + float response_power, + float3 response_anchor_ratio, + float physical_yf, + float3 anchor_lms) { + float2 neutral_mb = psycho30_AdaptiveNeutralMB(); + float2 source_offset = source_mb - neutral_mb; + float source_radius2 = dot(source_offset, source_offset); + if (!(source_radius2 > PSYCHO30_EPSILON2)) return 0.f; + + float2 vertices[3]; + [unroll] + for (int channel = 0; channel < 3; ++channel) { + float3 primary_lms = float3( + PSYCHO30_BT709_TO_LMS_MAT[0][channel], + PSYCHO30_BT709_TO_LMS_MAT[1][channel], + PSYCHO30_BT709_TO_LMS_MAT[2][channel]); + uint primary_valid; + vertices[channel] = psycho30_MBFromRelativeLMS( + primary_lms / anchor_lms, + primary_valid); + } + float2 source_direction = source_offset * rsqrt(source_radius2); + float source_boundary_radius = min( + psycho30_RaySegmentRadius( + neutral_mb, + source_direction, + vertices[0], + vertices[1]), + min( + psycho30_RaySegmentRadius( + neutral_mb, + source_direction, + vertices[1], + vertices[2]), + psycho30_RaySegmentRadius( + neutral_mb, + source_direction, + vertices[2], + vertices[0]))); + if (!(source_boundary_radius < PSYCHO30_LARGE_SUPPORT)) return 0.f; + + float2 boundary_mb = neutral_mb + + source_direction * max(source_boundary_radius, 0.f); + const float3 weights = renodx::color::CIE1702_MB_CIE_WEIGHTS; + float m_fraction = 1.f - boundary_mb.x; + if (!(boundary_mb.x > PSYCHO30_EPSILON) + || !(m_fraction > PSYCHO30_EPSILON) + || !(boundary_mb.y > PSYCHO30_EPSILON)) { + return 0.f; + } + float inverse_m_fraction = rcp(m_fraction); + float2 response_ratio = exp2( + log2(max( + float2( + boundary_mb.x * weights.y * inverse_m_fraction / weights.x, + boundary_mb.y * weights.y * inverse_m_fraction / weights.z), + float2(PSYCHO30_EPSILON, PSYCHO30_EPSILON))) + * response_power); + response_ratio *= float2( + response_anchor_ratio.x / response_anchor_ratio.y, + response_anchor_ratio.z / response_anchor_ratio.y); + float lm_ratio = (weights.x / weights.y) * response_ratio.x; + float sm_ratio = (weights.z / weights.y) * response_ratio.y; + float inverse_denominator = rcp(1.f + lm_ratio); + float2 response_boundary_mb = float2( + lm_ratio * inverse_denominator, + sm_ratio * inverse_denominator); + float3 boundary_lms = psycho30_LMSFromPhysicalYfMB( + physical_yf, + response_boundary_mb, + anchor_lms); + return length(psycho30_LinearA2Opponent(boundary_lms, anchor_lms)); +} + +float psycho30_NeutwoWithClip( + float x, + float peak, + float clip, + float h) { + x = max(x, 0.f); + peak = max(peak, 0.f); + if (!(peak > PSYCHO30_EPSILON)) return 0.f; + clip = max(clip, peak); + if (clip <= peak * (1.f + PSYCHO30_EPSILON)) return min(x, peak); + float q = saturate(x / clip); + float k = saturate(peak / clip); + float qh = pow(max(q, 0.f), h); + float kh = max(pow(max(k, PSYCHO30_EPSILON), h), 1e-37f); + float denominator = pow( + max(qh * (1.f - kh) + kh, 1e-37f), + rcp(h)); + return peak * q / max(denominator, PSYCHO30_EPSILON); +} + +// Defined-domain fallback for signed adaptation-relative LMS containing a zero +// or negative cone value. +// It uses sign-preserving cone power, linear A2 direction authoring, scalar Yf +// compression, weighted-MB source-boundary continuation, and analytic +// intersections with all lower and upper selected-target RGB-cube planes. +// This is an engineering continuity and full-strength target containment path. +float3 psycho30_LinearA2Fallback( + float3 input_lms, + float3 anchor_in_lms, + float3 anchor_out_lms, + int target_gamut_mode, + float target_rgb_peak, + float response_power, + float response_h, + float target_compression_strength) { + float3 source_q = input_lms / anchor_in_lms; + float3 response_lms = anchor_out_lms + * psycho30_ApplySignedConeResponseFallback( + source_q, + response_power); + + float2 source_opponent = psycho30_LinearA2Opponent( + input_lms, + anchor_in_lms); + float2 response_opponent = psycho30_LinearA2Opponent( + response_lms, + anchor_in_lms); + float source_radius2 = dot(source_opponent, source_opponent); + float response_radius2 = dot(response_opponent, response_opponent); + float3 authored_lms = response_lms; + if (source_radius2 > PSYCHO30_EPSILON2 + && response_radius2 > PSYCHO30_EPSILON2) { + float inverse_source_radius = rsqrt(source_radius2); + float inverse_response_radius = rsqrt(response_radius2); + float response_radius = response_radius2 * inverse_response_radius; + float2 midpoint = source_opponent * inverse_source_radius + + response_opponent * inverse_response_radius; + float midpoint_length2 = dot(midpoint, midpoint); + if (midpoint_length2 > PSYCHO30_EPSILON2) { + authored_lms = psycho30_LMSFromLinearA2Opponent( + midpoint * rsqrt(midpoint_length2) * response_radius, + max(renodx::color::yf::from::LMS(response_lms), 0.f), + anchor_in_lms); + } + } + + float neutral_yf_limit = psycho30_TargetNeutralYfLimit( + target_rgb_peak, + anchor_in_lms, + target_gamut_mode); + if (!(neutral_yf_limit > PSYCHO30_EPSILON)) { + return float3(0.f, 0.f, 0.f); + } + float anchor_out_yf = renodx::color::yf::from::LMS(anchor_out_lms); + float target_yf = psycho30_FiniteEndpoint( + max(renodx::color::yf::from::LMS(response_lms), 0.f), + anchor_out_yf, + neutral_yf_limit, + response_h); + + uint authored_mb_valid; + float2 authored_mb = psycho30_MBFromRelativeLMS( + authored_lms / anchor_in_lms, + authored_mb_valid); + if (authored_mb_valid == 0u) { + return psycho30_LMSFromYfOpponent( + target_yf, + 0.f, + 0.f, + anchor_in_lms); + } + + float3 desired_lms = psycho30_LMSFromPhysicalYfMB( + target_yf, + authored_mb, + anchor_in_lms); + float2 desired_opponent = psycho30_LinearA2Opponent( + desired_lms, + anchor_in_lms); + float desired_magnitude2 = dot(desired_opponent, desired_opponent); + if (!(desired_magnitude2 > PSYCHO30_EPSILON2)) { + return psycho30_LMSFromYfOpponent( + target_yf, + 0.f, + 0.f, + anchor_in_lms); + } + + float inverse_desired_magnitude = rsqrt(desired_magnitude2); + float desired_magnitude = desired_magnitude2 * inverse_desired_magnitude; + float2 direction = desired_opponent * inverse_desired_magnitude; + float source_clip_magnitude = max( + psycho30_TransformedSourceClipLinearA2Magnitude( + psycho30_MBFromRelativeLMS(source_q, authored_mb_valid), + response_power, + anchor_out_lms / anchor_in_lms, + target_yf, + anchor_in_lms), + desired_magnitude); + float target_support = psycho30_LinearA2TargetSupport( + direction, + source_clip_magnitude, + target_yf, + anchor_in_lms, + target_gamut_mode, + target_rgb_peak); + float compressed_magnitude = min( + psycho30_NeutwoWithClip( + desired_magnitude, + target_support, + max(source_clip_magnitude, target_support), + response_h), + target_support); + return psycho30_LMSFromLinearA2Opponent( + direction * lerp(desired_magnitude, compressed_magnitude, target_compression_strength), + target_yf, + anchor_in_lms); +} + +float3 psychotm_test30( + // Direct linear-light BT.709 RGB. + // Configuration values are trusted; only the input color is sanitized. + float3 bt709_linear_input, + float peak_value = 1000.f / 203.f, // display peak / reference white + float exposure = 1.f, // linear-light multiplier + float highlights = 1.f, // scalar-Yf highlight grade + float shadows = 1.f, // scalar-Yf shadow grade + float contrast = 1.f, // factor in common cone power p + float purity_scale = 1.f, // adaptation-relative LMS purity + float bleaching_intensity = 1.f, // positional compatibility placeholder + float clip_point = 100.f, // positional compatibility placeholder + float hue_restore = 1.f, // positional compatibility placeholder + float encoded_response_power = 1.f, // positional compatibility placeholder + int white_curve_mode = 0, // positional compatibility placeholder + float cone_response_exponent = 1.f, // second factor in cone power p + float3 current_adaptive_state_bt709 = 0.18f, // input anchor + float3 current_background_state_bt709 = 0.18f, // output anchor + float gamut_compression = 1.f, // target-projection strength + int gamut_compression_mode = 1, // 0 = BT.709, nonzero = BT.2020 + float adaptive_normalization = 1.f, // positional compatibility placeholder + float compression = 0.f) { // positive manual h; 0 = auto + // ------------------------------------------------------------------------- + // Source signal and signed-domain policy. + // ------------------------------------------------------------------------- + float3 sanitized_input = renodx::math::ZeroNaN(bt709_linear_input); + sanitized_input = renodx::math::Select( + isinf(sanitized_input), + renodx::math::CopySign( + float3( + PSYCHO30_MAX_FINITE_INPUT, + PSYCHO30_MAX_FINITE_INPUT, + PSYCHO30_MAX_FINITE_INPUT), + sanitized_input), + sanitized_input); + float3 exposed_input = sanitized_input * exposure; + + float3 anchored_lms = psycho30_AnchorSourcePositiveTotalToYf(exposed_input); + if (all(anchored_lms == float3(0.f, 0.f, 0.f))) { + return float3(0.f, 0.f, 0.f); + } + + float3 anchor_in_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + current_adaptive_state_bt709); + float3 anchor_out_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + current_background_state_bt709); + + // ------------------------------------------------------------------------- + // Observer-basis controls: scalar physiological-Yf grading followed by + // adaptation-relative LMS purity. These precede the finite cone response. + // ------------------------------------------------------------------------- + float3 graded_lms = anchored_lms; + [branch] + if (highlights != 1.f || shadows != 1.f) { + graded_lms = abs(anchored_lms); + float graded_yf = max( + renodx::color::yf::from::LMS(graded_lms), + PSYCHO30_EPSILON); + float adapted_anchor_yf = renodx::color::yf::from::LMS(anchor_in_lms); + float graded_yf_out = psycho30_HighlightsScalar( + graded_yf, + highlights, + adapted_anchor_yf); + graded_yf_out = psycho30_ShadowsScalar( + graded_yf_out, + shadows, + adapted_anchor_yf); + graded_lms *= renodx::math::DivideSafe( + graded_yf_out, + graded_yf, + 1.f); + graded_lms = renodx::math::CopySign(graded_lms, anchored_lms); + } + + float response_scale = cone_response_exponent; + float response_power = contrast * response_scale; + float purity_delta = renodx::math::DivideSafe( + purity_scale, + contrast, + 1.f); + float3 response_input_lms = psycho30_ApplyAdaptiveLMSPurity( + graded_lms, + anchor_in_lms, + purity_delta); + + // ------------------------------------------------------------------------- + // Positive finite-G response and Mean-A2 direction authoring. + // ------------------------------------------------------------------------- + float target_rgb_peak = peak_value; + float3 target_peak_lms = PSYCHO30_D65_WHITE_LMS * target_rgb_peak; + float response_h = compression; + [branch] + if (compression == PSYCHO30_AUTO_COMPRESSION_SENTINEL) { + response_h = psycho30_AutoCompressionPower( + renodx::color::yf::from::LMS(anchor_out_lms), + psycho30_TargetNeutralYfLimit( + target_rgb_peak, + anchor_in_lms, + gamut_compression_mode)); + } + + float response_yf; + uint response_valid; + float3 desired_coord = psycho30_MeanA2Response( + response_input_lms, + anchor_in_lms, + anchor_out_lms, + target_peak_lms, + response_power, + response_h, + response_yf, + response_valid); + [branch] + if (response_valid == 0u) { + // Signed cone states use the separate defined-domain path. + float3 fallback_lms = psycho30_LinearA2Fallback( + response_input_lms, + anchor_in_lms, + anchor_out_lms, + gamut_compression_mode, + target_rgb_peak, + response_power, + response_h, + gamut_compression); + float3 fallback_bt709 = mul( + PSYCHO30_LMS_TO_BT709_MAT, + fallback_lms); + return !any(isnan(fallback_bt709)) && !any(isinf(fallback_bt709)) + ? fallback_bt709 + : float3(0.f, 0.f, 0.f); + } + + // ------------------------------------------------------------------------- + // Device mapping: exact fixed-direction projection into the selected + // normalized RGB cube with the post-response physiological-Yf ceiling. + // ------------------------------------------------------------------------- + float target_compression_weight = gamut_compression; + float3 selected_coord = desired_coord; + if (target_compression_weight != 0.f) { + uint solve_valid; + float3 solved_coord = psycho30_YfCeilingSolve( + desired_coord, + response_yf, + gamut_compression_mode, + solve_valid); + if (solve_valid == 0u) return float3(0.f, 0.f, 0.f); + selected_coord = target_compression_weight == 1.f + ? solved_coord + : lerp( + desired_coord, + solved_coord, + target_compression_weight); + } + + // Direct inverse A2/Yf basis to linear BT.709. This is algebraically the + // normalized cone-coordinate inverse plus LMS-to-BT.709 matrix product. + float output_a = selected_coord.y * rsqrt(3.f) + + PSYCHO30_D65_ALPHA_DELTA + * selected_coord.x * rsqrt(2.f) + - selected_coord.z * rsqrt(6.f); + float3 output_bt709 = peak_value + * (output_a + + selected_coord.x * PSYCHO30_BT709_A2_X_RGB + + selected_coord.z * PSYCHO30_BT709_A2_Z_RGB); + return !any(isnan(output_bt709)) && !any(isinf(output_bt709)) + ? output_bt709 + : float3(0.f, 0.f, 0.f); +} + +static const int PSYCHO30_TARGET_GAMUT_BT709 = 0; +static const int PSYCHO30_TARGET_GAMUT_BT2020 = 1; +static const int PSYCHO30_TARGET_GAMUT_DISPLAY_P3 = 3; +static const float PSYCHO30_CUSTOM_GAMUT_COMPRESSION_KNEE = 0.9f; +// (0, 1] guarantees monotonic containment; 1 is the firmest valid response. +static const float PSYCHO30_CUSTOM_GAMUT_COMPRESSION_FIRMNESS = 0.65f; +static const float PSYCHO30_CUSTOM_GAMUT_COMPRESSION_EXP2_SCALE = PSYCHO30_CUSTOM_GAMUT_COMPRESSION_FIRMNESS / log(2.f); + +static const float3x3 PSYCHO30_LMS_TO_DISPLAY_P3_MAT = mul( + renodx::color::XYZ_TO_DISPLAYP3_MAT, + renodx::color::STOCKMAN_CVRL_LMS_TO_XYZ_2DEG_FIT); +static const float3 PSYCHO30_DISPLAY_P3_A2_X_RGB = mul( + PSYCHO30_LMS_TO_DISPLAY_P3_MAT, + float3( + sqrt(2.f) * PSYCHO30_D65_ALPHA_M + * PSYCHO30_D65_WHITE_LMS.x, + -sqrt(2.f) * PSYCHO30_D65_ALPHA_L + * PSYCHO30_D65_WHITE_LMS.y, + rsqrt(2.f) + * (PSYCHO30_D65_ALPHA_M + - PSYCHO30_D65_ALPHA_L) + * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_DISPLAY_P3_A2_Z_RGB = mul( + PSYCHO30_LMS_TO_DISPLAY_P3_MAT, + float3(0.f, 0.f, sqrt(6.f) * 0.5f * PSYCHO30_D65_WHITE_LMS.z)); + +// Streamlined Test30 variant. Anchored tonal grading replaces the original +// scalar-Yf highlights/shadows, common cone power, and finite-G response. Its +// output anchor is also the exact C-infinity shoulder anchor. The resulting +// per-cone response still supplies Test30's Mean-A2 direction/radius and exact +// fixed-direction target-cube projection. +float3 psycho30_CustomCInfinityTransition(float3 position) { + position = saturate(position); + return rcp(1.f + exp2((1.f - 2.f * position) / (position * (1.f - position)))); +} + +float3 psycho30_ApplyAnchoredTonalGrading( + float3 color, + float3 anchor_in, float3 anchor_out, + float contrast, float flare, + float highlight_contrast, float shadow_contrast, + float highlights, float shadows) { + [branch] + if (contrast == 1.f && flare == 0.f + && highlight_contrast == 1.f && shadow_contrast == 1.f + && highlights == 1.f && shadows == 1.f + && all(anchor_in == anchor_out)) { + return color; + } + + float3 normalized = color / anchor_in; + float3 graded_normalized = normalized; + + // Power contrast below the anchor and bounded log-domain contrast above it. + // Flare increases only the deep-shadow exponent. + [branch] + if (contrast != 1.f || flare > 0.f) { + float3 exponent = contrast; + + [branch] + if (flare > 0.f) { + float3 shadow_distance = saturate(1.f - normalized); + float3 flat_shadow_weight = exp2(-normalized / shadow_distance); + exponent *= mad(flat_shadow_weight, flare / (normalized + flare), 1.f); + } + + float3 input_stops = log2(normalized); + float3 highlight_stops = max(input_stops, 0.f); + float3 output_highlight_stops = highlight_stops; + + [branch] + if (contrast != 1.f) { + float3 displacement = (contrast - 1.f) * highlight_stops; + float3 displacement_magnitude = abs(displacement); + output_highlight_stops += displacement / mad(displacement_magnitude, exp2(-1.f / displacement_magnitude), 1.f); + } + + graded_normalized = exp2(mad(exponent, min(input_stops, 0.f), output_highlight_stops)); + } + + [branch] + if (highlight_contrast != 1.f) { + float3 distance = max(graded_normalized - 1.f, 0.f); + float3 distance_squared = distance * distance; + float3 flat_distance = (1.f + distance_squared) * exp2(-1.f / distance_squared); + graded_normalized += distance * (pow(1.f + flat_distance, 0.5f * (highlight_contrast - 1.f)) - 1.f); + } + + [branch] + if (shadow_contrast != 1.f) { + float3 distance = saturate(1.f - graded_normalized); + float3 distance_squared = distance * distance; + float3 flat_distance = distance_squared * distance * exp2(1.f - 1.f / distance_squared); + graded_normalized *= pow(1.f + flat_distance, shadow_contrast - 1.f); + } + + [branch] + if (highlights != 1.f || shadows != 1.f) { + static const float TONAL_OFFSET_START_STOPS = 1.f; + static const float TONAL_OFFSET_END_STOPS = 8.f; + static const float TONAL_OFFSET_INVERSE_RANGE_STOPS = 1.f / (TONAL_OFFSET_END_STOPS - TONAL_OFFSET_START_STOPS); + + float3 tonal_stops = log2(graded_normalized); + float3 tonal_displacement = 0.f; + + [branch] + if (highlights != 1.f) { + float adjustment = highlights - 1.f; + float displacement = adjustment * mad(1.5f, abs(adjustment), 0.5f); + float3 weight = psycho30_CustomCInfinityTransition( + (tonal_stops - TONAL_OFFSET_START_STOPS) + * TONAL_OFFSET_INVERSE_RANGE_STOPS); + tonal_displacement = mad(displacement, weight, tonal_displacement); + } + + [branch] + if (shadows != 1.f) { + float adjustment = shadows - 1.f; + float displacement = adjustment * mad(1.5f, abs(adjustment), 0.5f); + float3 weight = psycho30_CustomCInfinityTransition( + (-TONAL_OFFSET_START_STOPS - tonal_stops) + * TONAL_OFFSET_INVERSE_RANGE_STOPS); + tonal_displacement = mad(displacement, weight, tonal_displacement); + } + + graded_normalized *= exp2(tonal_displacement); + } + + return graded_normalized * anchor_out; +} + +float3 psycho30_ApplyAnchoredCInfinityShoulder( + float3 color, + float3 peak, + float3 anchor, + float compression_strength) { + float3 shoulder_range = peak - anchor; + float3 distance_from_anchor = max(color - anchor, 0.f); + float3 flat_weight = exp2(-shoulder_range / (compression_strength * distance_from_anchor)); + float3 response_denominator = mad(distance_from_anchor, flat_weight, shoulder_range); + return mad(shoulder_range, distance_from_anchor / response_denominator, color - distance_from_anchor); +} + +// Construct Test30's orthonormal response coordinate from the precomputed +// nonnegative per-cone response. A source weight of 0 selects the response +// direction; 1 reproduces Test30's exact source/response angular midpoint. +float3 psycho30_MeanA2ResponseFromCustomResponse( + float3 source_q, + float3 response_u, + float source_direction_weight, + out float response_yf, + out uint valid) { + valid = all(source_q >= float3(0.f, 0.f, 0.f)) + && all(response_u >= float3(0.f, 0.f, 0.f)) + && !any(isnan(source_q)) + && !any(isinf(source_q)) + && !any(isnan(response_u)) + && !any(isinf(response_u)) + ? 1u + : 0u; + if (valid == 0u) { + response_yf = 0.f; + return float3(0.f, 0.f, 0.f); + } + + float2 source_a2 = float2( + (source_q.x - source_q.y) * rsqrt(2.f), + (2.f * source_q.z - source_q.x - source_q.y) * rsqrt(6.f)); + float2 response_a2 = float2( + (response_u.x - response_u.y) * rsqrt(2.f), + (2.f * response_u.z - response_u.x - response_u.y) * rsqrt(6.f)); + float2 authored_a2 = response_a2; + float source_radius2 = dot(source_a2, source_a2); + float response_radius2 = dot(response_a2, response_a2); + + if (source_radius2 > PSYCHO30_EPSILON2 + && response_radius2 > PSYCHO30_EPSILON2) { + float inverse_response_radius = rsqrt(response_radius2); + float response_radius = response_radius2 * inverse_response_radius; + float2 mean_direction = mad( + source_a2, + rsqrt(source_radius2) * source_direction_weight, + response_a2 * inverse_response_radius); + float mean_radius2 = dot(mean_direction, mean_direction); + if (mean_radius2 > PSYCHO30_EPSILON2) { + authored_a2 = mean_direction + * rsqrt(mean_radius2) + * response_radius; + } + } + + response_yf = PSYCHO30_D65_ALPHA_L * response_u.x + + PSYCHO30_D65_ALPHA_M * response_u.y; + return float3( + authored_a2.x, + (response_u.x + response_u.y + response_u.z) * rsqrt(3.f), + authored_a2.y); +} + +// Preserve the custom path's authored A2 direction and desired physiological +// A while smoothly reducing radius against all six target RGB-cube planes. +// The response Yf remains an upper A ceiling. Working in scale space avoids +// direction normalization and keeps the common below-knee path division-free. +float3 psycho30_ApplyCustomSoftRadialGamutCompression( + float3 desired_coord, + float response_yf, + int target_gamut_mode, + out uint valid) { + valid = !any(isnan(desired_coord)) + && !any(isinf(desired_coord)) + && !isnan(response_yf) + && !isinf(response_yf) + ? 1u + : 0u; + if (valid == 0u) return float3(0.f, 0.f, 0.f); + + float radial_yf = PSYCHO30_D65_ALPHA_DELTA + * desired_coord.x * rsqrt(2.f) + - desired_coord.z * rsqrt(6.f); + float desired_a = desired_coord.y * rsqrt(3.f) + radial_yf; + float mapped_a = clamp(desired_a, 0.f, saturate(response_yf)); + float3 radial_rgb; + [branch] + if (target_gamut_mode == PSYCHO30_TARGET_GAMUT_BT709) { + radial_rgb = desired_coord.x * PSYCHO30_BT709_A2_X_RGB + + desired_coord.z * PSYCHO30_BT709_A2_Z_RGB; + } else if (target_gamut_mode == PSYCHO30_TARGET_GAMUT_DISPLAY_P3) { + radial_rgb = desired_coord.x * PSYCHO30_DISPLAY_P3_A2_X_RGB + + desired_coord.z * PSYCHO30_DISPLAY_P3_A2_Z_RGB; + } else { + radial_rgb = desired_coord.x * PSYCHO30_BT2020_A2_X_RGB + + desired_coord.z * PSYCHO30_BT2020_A2_Z_RGB; + } + + float positive_pressure = renodx::math::Max(radial_rgb); + float negative_pressure = -renodx::math::Min(radial_rgb); + if (positive_pressure + <= PSYCHO30_CUSTOM_GAMUT_COMPRESSION_KNEE * (1.f - mapped_a) + && negative_pressure + <= PSYCHO30_CUSTOM_GAMUT_COMPRESSION_KNEE * mapped_a) { + return float3( + desired_coord.x, + sqrt(3.f) * (mapped_a - radial_yf), + desired_coord.z); + } + + float support_scale = PSYCHO30_LARGE_SUPPORT; + if (positive_pressure > PSYCHO30_EPSILON) { + support_scale = min( + support_scale, + (1.f - mapped_a) / positive_pressure); + } + if (negative_pressure > PSYCHO30_EPSILON) { + support_scale = min( + support_scale, + mapped_a / negative_pressure); + } + if (!(support_scale < PSYCHO30_LARGE_SUPPORT)) { + return float3( + desired_coord.x, + sqrt(3.f) * (mapped_a - radial_yf), + desired_coord.z); + } + + support_scale = max(support_scale, 0.f); + float knee_scale = + PSYCHO30_CUSTOM_GAMUT_COMPRESSION_KNEE * support_scale; + float headroom = support_scale - knee_scale; + float excess = 1.f - knee_scale; + float headroom_per_excess = headroom * rcp(excess); + float flat_weight = exp2(-PSYCHO30_CUSTOM_GAMUT_COMPRESSION_EXP2_SCALE * headroom_per_excess); + float mapped_scale = knee_scale + headroom * rcp(headroom_per_excess + flat_weight); + mapped_scale = clamp( + mapped_scale, + 0.f, + min(1.f, support_scale)); + + float2 mapped_a2 = desired_coord.xz * mapped_scale; + float mapped_c0 = sqrt(3.f) + * (mapped_a - radial_yf * mapped_scale); + float3 mapped_coord = float3(mapped_a2.x, mapped_c0, mapped_a2.y); + valid = !any(isnan(mapped_coord)) && !any(isinf(mapped_coord)) ? 1u : 0u; + return valid != 0u ? mapped_coord : float3(0.f, 0.f, 0.f); +} + +float3 psychotm_custom_test30( + float3 bt709_linear_input, + float peak_value = 1000.f / 203.f, + float exposure = 1.f, + float highlights = 1.f, + float shadows = 1.f, + float contrast = 1.f, + float flare = 0.f, + float highlight_contrast = 1.f, + float shadow_contrast = 1.f, + float purity_scale = 1.f, + float highlight_saturation = 1.f, + float dechroma = 0.f, + float3 current_adaptive_state_bt709 = 0.18f, + float3 current_background_state_bt709 = 0.18f, + float gamut_compression = 1.f, + int gamut_compression_mode = PSYCHO30_TARGET_GAMUT_BT2020, + float compression = 1.5f, + float mean_a2_source_weight = 1.f) { + // Use the corrected Test30 input sanitization and source-boundary policy. + float3 sanitized_input = renodx::math::ZeroNaN(bt709_linear_input); + sanitized_input = renodx::math::Select( + isinf(sanitized_input), + renodx::math::CopySign( + float3( + PSYCHO30_MAX_FINITE_INPUT, + PSYCHO30_MAX_FINITE_INPUT, + PSYCHO30_MAX_FINITE_INPUT), + sanitized_input), + sanitized_input); + float3 exposed_input = sanitized_input * exposure; + + float3 source_lms = psycho30_AnchorSourcePositiveTotalToYf(exposed_input); + if (all(source_lms == float3(0.f, 0.f, 0.f))) { + return float3(0.f, 0.f, 0.f); + } + + float3 anchor_in_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + current_adaptive_state_bt709); + float3 anchor_out_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + current_background_state_bt709); + float3 target_peak_lms = PSYCHO30_D65_WHITE_LMS * peak_value; + + float3 tonal_input_lms = source_lms; + [branch] + if (purity_scale != 1.f || highlight_saturation != 1.f || dechroma != 0.f) { + float effective_purity_scale = purity_scale; + + // Author the highlight controls in the same adaptation-relative Yf + // coordinate used by the corrected LMS purity interpolation. + [branch] + if (dechroma != 0.f || highlight_saturation != 1.f) { + static const float INVERSE_HIGHLIGHT_RANGE_STOPS = + 1.f / (2.75f * log2(10.f)); + static const float HIGHLIGHT_ROLLOFF_CUBIC_BLEND = 0.5f; + static const float HIGHLIGHT_PURITY_STRENGTH = 2.f / 3.f; + + float source_relative_yf = max( + renodx::color::yf::from::LMS(source_lms / anchor_in_lms), + 0.f); + float neutral_relative_yf = renodx::color::yf::from::LMS( + float3(1.f, 1.f, 1.f)); + float luminance_from_neutral = max(source_relative_yf, neutral_relative_yf) / neutral_relative_yf; + float rolloff_position = saturate(log2(luminance_from_neutral) * INVERSE_HIGHLIGHT_RANGE_STOPS); + float rolloff_position_squared = rolloff_position * rolloff_position; + float rolloff = rolloff_position_squared * rolloff_position * mad(rolloff_position, mad(6.f, rolloff_position, -15.f), 10.f); + + if (dechroma != 0.f) { + effective_purity_scale *= mad(-dechroma, rolloff, 1.f); + } + + if (highlight_saturation != 1.f) { + float highlight_rolloff = rolloff * rolloff + * mad( + HIGHLIGHT_ROLLOFF_CUBIC_BLEND, + rolloff, + 1.f - HIGHLIGHT_ROLLOFF_CUBIC_BLEND); + effective_purity_scale *= mad( + highlight_saturation - 1.f, + highlight_rolloff * HIGHLIGHT_PURITY_STRENGTH, + 1.f); + } + } + + tonal_input_lms = psycho30_ApplyAdaptiveLMSPurity(source_lms, anchor_in_lms, effective_purity_scale); + } + tonal_input_lms = max(tonal_input_lms, 0.f); + + // Grade the three physical LMS cone components independently after purity. + // Physiological Yf is not used by the tonal grading stage. + float3 graded_lms = psycho30_ApplyAnchoredTonalGrading( + tonal_input_lms, + anchor_in_lms, + anchor_out_lms, + contrast, + flare, + highlight_contrast, + shadow_contrast, + highlights, + shadows); + float3 response_lms = psycho30_ApplyAnchoredCInfinityShoulder( + graded_lms, + target_peak_lms, + anchor_out_lms, + compression); + + float3 source_q = tonal_input_lms / anchor_in_lms; + float3 response_u = response_lms / target_peak_lms; + float response_yf; + uint response_valid; + float3 desired_coord = psycho30_MeanA2ResponseFromCustomResponse( + source_q, + response_u, + mean_a2_source_weight, + response_yf, + response_valid); + if (response_valid == 0u) return float3(0.f, 0.f, 0.f); + + float3 selected_coord = desired_coord; + if (gamut_compression != 0.f) { + uint solve_valid; + float3 solved_coord = psycho30_ApplyCustomSoftRadialGamutCompression( + desired_coord, + response_yf, + gamut_compression_mode, + solve_valid); + if (solve_valid == 0u) return float3(0.f, 0.f, 0.f); + selected_coord = gamut_compression == 1.f + ? solved_coord + : lerp( + desired_coord, + solved_coord, + gamut_compression); + } + + float output_a = selected_coord.y * rsqrt(3.f) + PSYCHO30_D65_ALPHA_DELTA * selected_coord.x * rsqrt(2.f) - selected_coord.z * rsqrt(6.f); + float3 output_bt709 = peak_value * (output_a + selected_coord.x * PSYCHO30_BT709_A2_X_RGB + selected_coord.z * PSYCHO30_BT709_A2_Z_RGB); + return !any(isnan(output_bt709)) && !any(isinf(output_bt709)) + ? output_bt709 + : float3(0.f, 0.f, 0.f); +} + +} // namespace psychov +} // namespace tonemap +} // namespace renodx + +#endif // PSYCHOV_CUSTOMTEST30_HLSLI_ \ No newline at end of file diff --git a/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli b/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli index 717c3b950..95c4ce9cc 100644 --- a/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli +++ b/src/games/asscreedblackflagresynced/tonemap/tonemap.hlsli @@ -1,5 +1,5 @@ #include "../common.hlsli" -#include "./customtest25.hlsli" +#include "./customtest30.hlsli" #define ANVIL_ENGINE_TONEMAP_GENERATOR(T) \ T EvaluateAnvilEngineToeAndLinear(T input, float linear_slope, float toe_end, float toe_power, float toe_offset) { \ @@ -52,33 +52,7 @@ CUSTOM_ANVIL_ENGINE_TONEMAP_GENERATOR(float) CUSTOM_ANVIL_ENGINE_TONEMAP_GENERATOR(float3) #undef CUSTOM_ANVIL_ENGINE_TONEMAP_GENERATOR -float3 CompressAnvilEnginePsychoV25ReferenceScaleHull( - float3 desired_lms, - float3 direction_source_lms, - float3 adaptive_state_lms, - float3 target_lms_peak, - float shoulder_start_output, - float pre_shoulder_hue_linearity, - float post_shoulder_source_hue_recovery_strength, - float post_saturation, - float compression, - float peak_value, - int target_gamut_mode = renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_BT2020) { - return CompressPsychoV25ReferenceScaleHull( - desired_lms, - direction_source_lms, - adaptive_state_lms, - renodx::color::lms::from::AP1(shoulder_start_output.xxx), - target_lms_peak, - pre_shoulder_hue_linearity, - post_shoulder_source_hue_recovery_strength, - post_saturation, - compression, - peak_value, - target_gamut_mode); -} - -float3 ApplyCustomAnvilEnginePsychoV25ToneMap( +float3 ApplyCustomAnvilEnginePsychoV30ToneMap( float3 untonemapped_ap1, float peak_value, float linear_slope, @@ -88,34 +62,87 @@ float3 ApplyCustomAnvilEnginePsychoV25ToneMap( float toe_flare, float post_saturation, float shoulder_start, - float pre_shoulder_hue_linearity = 0.35f, - int target_gamut_mode = renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_BT2020, - float post_shoulder_source_hue_recovery_strength = 0.f, - float compression = 1.f) { - float3 white_lms = renodx::color::lms::from::AP1(1.f.xxx); - float3 untonemapped_lms = max(renodx::color::lms::from::AP1(untonemapped_ap1), 0.f); - - // The shoulder operates on the toe/linear output. - // Use its output-domain start as the adaptive output anchor. - const float output_anchor = shoulder_start; - // Find the input whose toe/linear output reaches the shoulder start. + float mean_a2_source_weight = 1.f, + int target_gamut_mode = renodx::tonemap::psychov::PSYCHO30_TARGET_GAMUT_BT2020, + float compression = 1.5f, + float gamut_compression = 1.f) { + float3 finite_ap1_input = renodx::math::ZeroNaN(untonemapped_ap1); + finite_ap1_input = renodx::math::Select( + isinf(finite_ap1_input), + renodx::math::CopySign(renodx::tonemap::psychov::PSYCHO30_MAX_FINITE_INPUT.xxx, finite_ap1_input), + finite_ap1_input); + float3 finite_bt709_input = renodx::math::ZeroNaN(renodx::color::bt709::from::AP1(finite_ap1_input)); + finite_bt709_input = renodx::math::Select( + isinf(finite_bt709_input), + renodx::math::CopySign(renodx::tonemap::psychov::PSYCHO30_MAX_FINITE_INPUT.xxx, finite_bt709_input), + finite_bt709_input); + + float3 source_lms = renodx::tonemap::psychov::psycho30_AnchorSourcePositiveTotalToYf(finite_bt709_input); + if (all(source_lms == 0.f.xxx)) return 0.f.xxx; + float input_adaptive_anchor = toe_end + ((shoulder_start - toe_end) / linear_slope); - float3 input_adaptive_anchor_lms = input_adaptive_anchor * white_lms; - float3 toe_linear_lms = EvaluateCustomAnvilEngineToeAndLinear(untonemapped_lms / white_lms, linear_slope, toe_end, toe_power, toe_offset, toe_flare) * white_lms; - float3 peak_white_lms = peak_value * white_lms; + float3 input_adaptive_anchor_lms = input_adaptive_anchor * renodx::tonemap::psychov::PSYCHO30_D65_WHITE_LMS; + float3 output_adaptive_anchor_lms = shoulder_start * renodx::tonemap::psychov::PSYCHO30_D65_WHITE_LMS; + float3 target_peak_lms = peak_value * renodx::tonemap::psychov::PSYCHO30_D65_WHITE_LMS; - return renodx::color::ap1::from::LMS(CompressAnvilEnginePsychoV25ReferenceScaleHull( + float3 toe_linear_lms = + EvaluateCustomAnvilEngineToeAndLinear( + source_lms / renodx::tonemap::psychov::PSYCHO30_D65_WHITE_LMS, + linear_slope, + toe_end, + toe_power, + toe_offset, + toe_flare) + * renodx::tonemap::psychov::PSYCHO30_D65_WHITE_LMS; + float3 response_lms = renodx::tonemap::psychov::psycho30_ApplyAnchoredCInfinityShoulder( toe_linear_lms, - untonemapped_lms, - input_adaptive_anchor_lms, - peak_white_lms, - shoulder_start, - pre_shoulder_hue_linearity, - post_shoulder_source_hue_recovery_strength, - post_saturation, - compression, - peak_value, - target_gamut_mode)); + target_peak_lms, + output_adaptive_anchor_lms, + compression); + if (post_saturation != 1.f) { + response_lms = renodx::tonemap::psychov::psycho30_ApplyAdaptiveLMSPurity( + response_lms, + output_adaptive_anchor_lms, + post_saturation); + response_lms = max(response_lms, 0.f); + } + + float response_yf; + uint response_valid; + float3 desired_coord = renodx::tonemap::psychov::psycho30_MeanA2ResponseFromCustomResponse( + source_lms / input_adaptive_anchor_lms, + response_lms / target_peak_lms, + mean_a2_source_weight, + response_yf, + response_valid); + if (response_valid == 0u) return 0.f.xxx; + + float3 selected_coord = desired_coord; + if (gamut_compression != 0.f) { + uint solve_valid; + float3 solved_coord = renodx::tonemap::psychov::psycho30_ApplyCustomSoftRadialGamutCompression( + desired_coord, + response_yf, + target_gamut_mode, + solve_valid); + if (solve_valid == 0u) return 0.f.xxx; + selected_coord = gamut_compression == 1.f + ? solved_coord + : lerp(desired_coord, solved_coord, gamut_compression); + } + + float output_a = selected_coord.y * rsqrt(3.f) + + renodx::tonemap::psychov::PSYCHO30_D65_ALPHA_DELTA + * selected_coord.x * rsqrt(2.f) + - selected_coord.z * rsqrt(6.f); + float3 output_bt709 = peak_value + * (output_a + + selected_coord.x * renodx::tonemap::psychov::PSYCHO30_BT709_A2_X_RGB + + selected_coord.z * renodx::tonemap::psychov::PSYCHO30_BT709_A2_Z_RGB); + float3 output_ap1 = renodx::color::ap1::from::BT709(output_bt709); + return !any(isnan(output_ap1)) && !any(isinf(output_ap1)) + ? output_ap1 + : 0.f.xxx; } float3 Psycho23ToAdaptiveRelativeWeightedLMS( @@ -154,10 +181,10 @@ float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float disp float3 tonemapped_bt709; if (RENODX_TONE_MAP_TYPE == 2.f) { - int target_gamut_mode = renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_DISPLAY_P3; + int target_gamut_mode = renodx::tonemap::psychov::PSYCHO30_TARGET_GAMUT_DISPLAY_P3; if (!hdr_enabled) { target_peak_ratio = 1.f; - target_gamut_mode = renodx::tonemap::psychov::PSYCHO25_TARGET_GAMUT_BT709; + target_gamut_mode = renodx::tonemap::psychov::PSYCHO30_TARGET_GAMUT_BT709; } float linear_slope = 1.625f; @@ -168,7 +195,7 @@ float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float disp float toe_flare = 0.1f * pow(0.875f, 10.f); float post_saturation = 1.f; - float3 tonemapped_ap1 = ApplyCustomAnvilEnginePsychoV25ToneMap( + float3 tonemapped_ap1 = ApplyCustomAnvilEnginePsychoV30ToneMap( untonemapped_ap1, target_peak_ratio, linear_slope, @@ -178,7 +205,7 @@ float3 BuildToneMapLUTOutput(float3 untonemapped_ap1, float exposure, float disp toe_flare, post_saturation, shoulder_start, - 0.5f, + 1.f, target_gamut_mode); tonemapped_bt709 = renodx::color::bt709::from::AP1(tonemapped_ap1); From 869ba298d0d0c5921fac1a2fd60b7ed08a26abc2 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Mon, 24 Aug 2026 15:23:03 -0400 Subject: [PATCH 21/22] feat(rdr2vk): enable other tm shaders, adjust enhanced tm --- src/games/rdr2vk/addon.cpp | 8 +- ....glslx => deadeye_0xDA9A5AA0.frag.vk.glsl} | 0 ...glslx => eagleeye_0x6B9382CA.frag.vk.glsl} | 0 ...glslx => eagleeye_0xE3E0B5C4.frag.vk.glsl} | 0 ...vk.glslx => pause_0x2B012EDD.frag.vk.glsl} | 0 src/games/rdr2vk/tonemap/tonemap.glsl | 111 +++++++++++++++++- 6 files changed, 111 insertions(+), 8 deletions(-) rename src/games/rdr2vk/tonemap/{deadeye_0xDA9A5AA0.frag.vk.glslx => deadeye_0xDA9A5AA0.frag.vk.glsl} (100%) rename src/games/rdr2vk/tonemap/{eagleeye_0x6B9382CA.frag.vk.glslx => eagleeye_0x6B9382CA.frag.vk.glsl} (100%) rename src/games/rdr2vk/tonemap/{eagleeye_0xE3E0B5C4.frag.vk.glslx => eagleeye_0xE3E0B5C4.frag.vk.glsl} (100%) rename src/games/rdr2vk/tonemap/{pause_0x2B012EDD.frag.vk.glslx => pause_0x2B012EDD.frag.vk.glsl} (100%) diff --git a/src/games/rdr2vk/addon.cpp b/src/games/rdr2vk/addon.cpp index e31fd3137..ec78a28e1 100644 --- a/src/games/rdr2vk/addon.cpp +++ b/src/games/rdr2vk/addon.cpp @@ -29,19 +29,19 @@ void OnTonemapShaderDrawn(reshade::api::command_list*) { renodx::mods::shader::CustomShaders custom_shaders = { // tonemap {0x0C1275BE, {.crc32 = 0x0C1275BE, .code = __0x0C1275BE, .on_drawn = &OnTonemapShaderDrawn}}, - // {0x2B012EDD, {.crc32 = 0x2B012EDD, .code = __0x2B012EDD, .on_drawn = &OnTonemapShaderDrawn}}, + {0x2B012EDD, {.crc32 = 0x2B012EDD, .code = __0x2B012EDD, .on_drawn = &OnTonemapShaderDrawn}}, {0x4205843B, {.crc32 = 0x4205843B, .code = __0x4205843B, .on_drawn = &OnTonemapShaderDrawn}}, {0x54F0BD84, {.crc32 = 0x54F0BD84, .code = __0x54F0BD84, .on_drawn = &OnTonemapShaderDrawn}}, - // {0x6B9382CA, {.crc32 = 0x6B9382CA, .code = __0x6B9382CA, .on_drawn = &OnTonemapShaderDrawn}}, + {0x6B9382CA, {.crc32 = 0x6B9382CA, .code = __0x6B9382CA, .on_drawn = &OnTonemapShaderDrawn}}, {0x809F5852, {.crc32 = 0x809F5852, .code = __0x809F5852, .on_drawn = &OnTonemapShaderDrawn}}, {0x9B304112, {.crc32 = 0x9B304112, .code = __0x9B304112, .on_drawn = &OnTonemapShaderDrawn}}, {0x9F191B0B, {.crc32 = 0x9F191B0B, .code = __0x9F191B0B, .on_drawn = &OnTonemapShaderDrawn}}, {0xA2ED1CB7, {.crc32 = 0xA2ED1CB7, .code = __0xA2ED1CB7, .on_drawn = &OnTonemapShaderDrawn}}, {0xCD6F15F2, {.crc32 = 0xCD6F15F2, .code = __0xCD6F15F2, .on_drawn = &OnTonemapShaderDrawn}}, {0xCF7FE0D7, {.crc32 = 0xCF7FE0D7, .code = __0xCF7FE0D7, .on_drawn = &OnTonemapShaderDrawn}}, - // {0xDA9A5AA0, {.crc32 = 0xDA9A5AA0, .code = __0xDA9A5AA0, .on_drawn = &OnTonemapShaderDrawn}}, + {0xDA9A5AA0, {.crc32 = 0xDA9A5AA0, .code = __0xDA9A5AA0, .on_drawn = &OnTonemapShaderDrawn}}, {0xDD04030E, {.crc32 = 0xDD04030E, .code = __0xDD04030E, .on_drawn = &OnTonemapShaderDrawn}}, - // {0xE3E0B5C4, {.crc32 = 0xE3E0B5C4, .code = __0xE3E0B5C4, .on_drawn = &OnTonemapShaderDrawn}}, + {0xE3E0B5C4, {.crc32 = 0xE3E0B5C4, .code = __0xE3E0B5C4, .on_drawn = &OnTonemapShaderDrawn}}, // output CustomShaderEntry(0x14BF23D4), diff --git a/src/games/rdr2vk/tonemap/deadeye_0xDA9A5AA0.frag.vk.glslx b/src/games/rdr2vk/tonemap/deadeye_0xDA9A5AA0.frag.vk.glsl similarity index 100% rename from src/games/rdr2vk/tonemap/deadeye_0xDA9A5AA0.frag.vk.glslx rename to src/games/rdr2vk/tonemap/deadeye_0xDA9A5AA0.frag.vk.glsl diff --git a/src/games/rdr2vk/tonemap/eagleeye_0x6B9382CA.frag.vk.glslx b/src/games/rdr2vk/tonemap/eagleeye_0x6B9382CA.frag.vk.glsl similarity index 100% rename from src/games/rdr2vk/tonemap/eagleeye_0x6B9382CA.frag.vk.glslx rename to src/games/rdr2vk/tonemap/eagleeye_0x6B9382CA.frag.vk.glsl diff --git a/src/games/rdr2vk/tonemap/eagleeye_0xE3E0B5C4.frag.vk.glslx b/src/games/rdr2vk/tonemap/eagleeye_0xE3E0B5C4.frag.vk.glsl similarity index 100% rename from src/games/rdr2vk/tonemap/eagleeye_0xE3E0B5C4.frag.vk.glslx rename to src/games/rdr2vk/tonemap/eagleeye_0xE3E0B5C4.frag.vk.glsl diff --git a/src/games/rdr2vk/tonemap/pause_0x2B012EDD.frag.vk.glslx b/src/games/rdr2vk/tonemap/pause_0x2B012EDD.frag.vk.glsl similarity index 100% rename from src/games/rdr2vk/tonemap/pause_0x2B012EDD.frag.vk.glslx rename to src/games/rdr2vk/tonemap/pause_0x2B012EDD.frag.vk.glsl diff --git a/src/games/rdr2vk/tonemap/tonemap.glsl b/src/games/rdr2vk/tonemap/tonemap.glsl index ac7547858..253951e36 100644 --- a/src/games/rdr2vk/tonemap/tonemap.glsl +++ b/src/games/rdr2vk/tonemap/tonemap.glsl @@ -71,8 +71,13 @@ vec3 ApplyAnchoredAdaptationContrast( float shadows) { vec3 ax = abs(color); vec3 normalized = ax / anchor_in; - vec3 flare_ratio = vec3(1.0) + DivideSafe(vec3(flare), normalized + flare, vec3(0.0)); - vec3 exponent = contrast * flare_ratio; + vec3 exponent = vec3(contrast); + + if (flare > 0.0) { + vec3 shadow_distance = clamp(vec3(1.0) - normalized, vec3(0.0), vec3(1.0)); + vec3 flat_shadow_weight = exp2(-normalized / shadow_distance); + exponent *= fma(flat_shadow_weight, vec3(flare) / (normalized + flare), vec3(1.0)); + } vec3 ax_n = pow(ax, exponent); vec3 anchor_n = pow(anchor_in, exponent); @@ -183,6 +188,104 @@ vec3 ApplyAnchoredCInfinityBoundedPowerContrast( return sign(color) * contrasted_normalized * anchor_out; } +vec3 CustomCInfinityTransition(vec3 position) { + position = clamp(position, vec3(0.0), vec3(1.0)); + return vec3(1.0) / (vec3(1.0) + exp2((vec3(1.0) - 2.0 * position) / (position * (vec3(1.0) - position)))); +} + +vec3 ApplyAnchoredTonalGrading( + vec3 color, + vec3 anchor_in, + vec3 anchor_out, + float contrast, + float flare, + float highlight_contrast, + float shadow_contrast, + float highlights, + float shadows) { + if (contrast == 1.0 && flare == 0.0 + && highlight_contrast == 1.0 && shadow_contrast == 1.0 + && highlights == 1.0 && shadows == 1.0 + && all(equal(anchor_in, anchor_out))) { + return color; + } + + vec3 normalized = color / anchor_in; + vec3 graded_normalized = normalized; + + // Power contrast below the anchor and bounded log-domain contrast above it. + // Flare increases only the deep-shadow exponent. + if (contrast != 1.0 || flare > 0.0) { + vec3 exponent = vec3(contrast); + + if (flare > 0.0) { + vec3 shadow_distance = clamp(vec3(1.0) - normalized, vec3(0.0), vec3(1.0)); + vec3 flat_shadow_weight = exp2(-normalized / shadow_distance); + exponent *= fma(flat_shadow_weight, vec3(flare) / (normalized + flare), vec3(1.0)); + } + + vec3 input_stops = log2(normalized); + vec3 highlight_stops = max(input_stops, vec3(0.0)); + vec3 output_highlight_stops = highlight_stops; + + if (contrast != 1.0) { + vec3 displacement = (contrast - 1.0) * highlight_stops; + vec3 displacement_magnitude = abs(displacement); + output_highlight_stops += displacement + / fma(displacement_magnitude, + exp2(-vec3(1.0) / displacement_magnitude), + vec3(1.0)); + } + + graded_normalized = exp2(fma(exponent, min(input_stops, vec3(0.0)), output_highlight_stops)); + } + + if (highlight_contrast != 1.0) { + vec3 distance = max(graded_normalized - 1.0, vec3(0.0)); + vec3 distance_squared = distance * distance; + vec3 flat_distance = (vec3(1.0) + distance_squared) * exp2(-vec3(1.0) / distance_squared); + graded_normalized += distance + * (pow(vec3(1.0) + flat_distance, + vec3(0.5 * (highlight_contrast - 1.0))) + - 1.0); + } + + if (shadow_contrast != 1.0) { + vec3 distance = clamp(vec3(1.0) - graded_normalized, vec3(0.0), vec3(1.0)); + vec3 distance_squared = distance * distance; + vec3 flat_distance = distance_squared * distance * exp2(vec3(1.0) - vec3(1.0) / distance_squared); + graded_normalized *= pow(vec3(1.0) + flat_distance, vec3(shadow_contrast - 1.0)); + } + + if (highlights != 1.0 || shadows != 1.0) { + const float TONAL_OFFSET_START_STOPS = 1.0; + const float TONAL_OFFSET_END_STOPS = 8.0; + const float TONAL_OFFSET_INVERSE_RANGE_STOPS = + 1.0 / (TONAL_OFFSET_END_STOPS - TONAL_OFFSET_START_STOPS); + + vec3 tonal_stops = log2(graded_normalized); + vec3 tonal_displacement = vec3(0.0); + + if (highlights != 1.0) { + float adjustment = highlights - 1.0; + float displacement = adjustment * fma(1.5, abs(adjustment), 0.5); + vec3 weight = CustomCInfinityTransition((tonal_stops - TONAL_OFFSET_START_STOPS) * TONAL_OFFSET_INVERSE_RANGE_STOPS); + tonal_displacement = fma(vec3(displacement), weight, tonal_displacement); + } + + if (shadows != 1.0) { + float adjustment = shadows - 1.0; + float displacement = adjustment * fma(1.5, abs(adjustment), 0.5); + vec3 weight = CustomCInfinityTransition((-TONAL_OFFSET_START_STOPS - tonal_stops) * TONAL_OFFSET_INVERSE_RANGE_STOPS); + tonal_displacement = fma(vec3(displacement), weight, tonal_displacement); + } + + graded_normalized *= exp2(tonal_displacement); + } + + return graded_normalized * anchor_out; +} + // Identity through anchor to every derivative; then approaches peak // monotonically and concave down. Requires anchor < peak and compression_strength >= 1. float ApplyAnchoredCInfinityShoulder(float color, float peak, float anchor, float compression_strength) { @@ -241,8 +344,8 @@ vec3 ApplyToneMap(vec3 _676, bool _679, float _638, float _m6, uint _m4, float _ float anchor_out = rdr2_tonemap_Apply(pivot_point, A, B, C, D, E, F, white_precompute); vec3 anchor_out_lms = renodx_tonemap_psycho22_StockmanLMSFromBT709(vec3(anchor_out)); float pivot_slope = rdr2_tonemap_Derivative(pivot_point, A, B, C, D, E, F) * white_precompute; - tonemapped = ApplyAnchoredAdaptationContrast(untonemapped, (2.0 * pivot_slope * pivot_point / anchor_out - 1.0) * 1.22, renodx_tonemap_psycho22_StockmanLMSFromBT709(vec3(pivot_point)), anchor_out_lms, 0.10f * pow(0.72f, 10.f), 1.f, 1.f); - // tonemapped = ApplyAnchoredCInfinityBoundedPowerContrast(untonemapped, (pivot_slope * pivot_point / anchor_out), renodx_tonemap_psycho22_StockmanLMSFromBT709(vec3(pivot_point)), anchor_out_lms, 0.10f * pow(0.72f, 10.f), 1.f, 1.f); + // tonemapped = ApplyAnchoredAdaptationContrast(untonemapped, (2.0 * pivot_slope * pivot_point / anchor_out - 1.0) * 1.22, renodx_tonemap_psycho22_StockmanLMSFromBT709(vec3(pivot_point)), anchor_out_lms, 0.10f * pow(0.72f, 10.f), 1.f, 1.f); + tonemapped = ApplyAnchoredTonalGrading(untonemapped, renodx_tonemap_psycho22_StockmanLMSFromBT709(vec3(pivot_point)), anchor_out_lms, (pivot_slope * pivot_point / anchor_out), 0.f, 1.f, (30.f / 50.f), 1.f, (1.f / 50.f)); vec3 precompression_lms = tonemapped; float precompression_yf = renodx_color_yf_from_LMS(precompression_lms); From 6f9d106f27887d54c1f7e5519cdcdf69f2c3b7c0 Mon Sep 17 00:00:00 2001 From: Musa Haji Date: Mon, 24 Aug 2026 17:32:07 -0400 Subject: [PATCH 22/22] chore(avatarfop-swoutlaws): clean up lutbuilder --- .../avatarfop-swoutlaws/lutbuilder.hlsli | 100 ++---------------- 1 file changed, 9 insertions(+), 91 deletions(-) diff --git a/src/games/avatarfop-swoutlaws/lutbuilder.hlsli b/src/games/avatarfop-swoutlaws/lutbuilder.hlsli index 5d64dd1e9..910294455 100644 --- a/src/games/avatarfop-swoutlaws/lutbuilder.hlsli +++ b/src/games/avatarfop-swoutlaws/lutbuilder.hlsli @@ -34,7 +34,8 @@ SPLIT_CONTRAST_FUNCTION_GENERATOR(float3) #undef SPLIT_CONTRAST_FUNCTION_GENERATOR -float3 GenerateOutputAvatar(float3 ungraded_bt709, float contrast) { +float3 GenerateOutput(float3 ungraded_bt709, float shadow_contrast, float highlight_contrast, + float exposure_adjustment) { float3 graded_bt709; if (RENODX_TONE_MAP_TYPE == 1.f) { // None graded_bt709 = ungraded_bt709; @@ -49,12 +50,6 @@ float3 GenerateOutputAvatar(float3 ungraded_bt709, float contrast) { renodx::color::bt2020::from::BT709(corrected_ch), 1.f, 1.f)); } } else { // RenoDX - // `pow(c, contrast) * exposure_adjustment` per channel will essentially give uncapped version of vanilla - float shadow_contrast = contrast; - // lower highlight contrast so image isn't overly harsh, but make sure sun still reaches 100.f (10k nits with 100 game brightness) - float highlight_contrast = contrast * 0.681f; - float exposure_adjustment = 2.f; - // apply by yf to keep hues intact and scale lightness evenly float lum_in = renodx::color::yf::from::BT709(ungraded_bt709); float lum_out = SplitContrast(lum_in, shadow_contrast, highlight_contrast, 1.f) * exposure_adjustment; @@ -121,89 +116,12 @@ float3 GenerateOutputAvatar(float3 ungraded_bt709, float contrast) { return color_pq; } -float3 GenerateOutputOutlaws(float3 ungraded_bt709, float contrast) { - float3 graded_bt709; - if (RENODX_TONE_MAP_TYPE == 1.f) { // None - graded_bt709 = ungraded_bt709; - if (RENODX_SDR_EOTF_EMULATION == 2.f) { - float lum_in = renodx::color::y::from::BT709(ungraded_bt709); - float lum_out = renodx::color::correct::GammaSafe(lum_in); - float3 corrected_lum = renodx::color::correct::Luminance(ungraded_bt709, lum_in, lum_out); - - float3 corrected_ch = renodx::color::correct::GammaSafe(ungraded_bt709); - graded_bt709 = renodx::color::bt709::from::BT2020( - renodx_custom::tonemap::psycho::psycho17_ApplyPurityFromBT2020(renodx::color::bt2020::from::BT709(corrected_lum), - renodx::color::bt2020::from::BT709(corrected_ch), 1.f, 1.f)); - } - } else { // RenoDX - // `pow(c, contrast) * exposure_adjustment` per channel will essentially give uncapped version of vanilla - float shadow_contrast = contrast; - // lower highlight contrast so image isn't overly harsh, but make sure sun still reaches 100.f (10k nits with 100 game brightness) - float highlight_contrast = contrast * 0.7375f; - float exposure_adjustment = 1.7f; - - // apply by yf to keep hues intact and scale lightness evenly - float lum_in = renodx::color::yf::from::BT709(ungraded_bt709); - float lum_out = SplitContrast(lum_in, shadow_contrast, highlight_contrast, 1.f) * exposure_adjustment; - if (RENODX_SDR_EOTF_EMULATION == 2.f) { - lum_out = renodx::color::correct::GammaSafe(lum_out); - } - float3 contrasted_lum = renodx::color::correct::Luminance(ungraded_bt709, lum_in, lum_out); - - // apply grading per channel as reference color to take purity from - float3 contrasted_ch = SplitContrast(ungraded_bt709, shadow_contrast, highlight_contrast, 1.f) * exposure_adjustment; - if (RENODX_SDR_EOTF_EMULATION == 2.f) { - contrasted_ch = renodx::color::correct::GammaSafe(contrasted_ch); - } - - // apply purity of per channel contrasted to yf contrasted - // this gives us our graded color which will be tonemapped to monitor peak later - graded_bt709 = renodx::color::bt709::from::BT2020( - renodx_custom::tonemap::psycho::psycho17_ApplyPurityFromBT2020( - renodx::color::bt2020::from::BT709(contrasted_ch), - renodx::color::bt2020::from::BT709(contrasted_lum), 1.f, 1.f)); - } - - if (RENODX_SDR_EOTF_EMULATION == 1.f) { - graded_bt709 = renodx::color::correct::GammaSafe(graded_bt709); - } - - float3 final_bt709 = graded_bt709; - - float3 color_bt2020 = renodx::color::bt2020::from::BT709(final_bt709); - - renodx_custom::tonemap::psycho::config17::Config psycho17_config = - renodx_custom::tonemap::psycho::config17::Create(); - psycho17_config.peak_value = RENODX_PEAK_WHITE_NITS / RENODX_DIFFUSE_WHITE_NITS; - psycho17_config.clip_point = RENODX_TONE_MAP_WHITE_CLIP; - psycho17_config.exposure = RENODX_TONE_MAP_EXPOSURE; - psycho17_config.gamma = RENODX_TONE_MAP_GAMMA; - psycho17_config.highlights = RENODX_TONE_MAP_HIGHLIGHTS; - psycho17_config.shadows = RENODX_TONE_MAP_SHADOWS; - psycho17_config.contrast = RENODX_TONE_MAP_CONTRAST; - psycho17_config.flare = 0.10f * pow(RENODX_TONE_MAP_FLARE, 10.f); - psycho17_config.contrast_highlights = RENODX_TONE_MAP_CONTRAST_HIGHLIGHTS; - psycho17_config.contrast_shadows = RENODX_TONE_MAP_CONTRAST_SHADOWS; - psycho17_config.purity_scale = RENODX_TONE_MAP_SATURATION; - psycho17_config.purity_highlights = -1.f * (RENODX_TONE_MAP_HIGHLIGHT_SATURATION - 1.f); - psycho17_config.dechroma = RENODX_TONE_MAP_DECHROMA; - psycho17_config.adaptation_contrast = RENODX_TONE_MAP_ADAPTATION_CONTRAST; - psycho17_config.bleaching_intensity = 0.f; - psycho17_config.hue_emulation = RENODX_TONE_MAP_HUE_EMULATION; - psycho17_config.pre_gamut_compress = false; - psycho17_config.post_gamut_compress = true; - - if (RENODX_TONE_MAP_TYPE == 1.f) { - psycho17_config.apply_tonemap = false; - } - - float3 hue_shift_source_bt2020 = color_bt2020; - if (psycho17_config.hue_emulation != 0.f) { - hue_shift_source_bt2020 = renodx::tonemap::ReinhardPiecewise(color_bt2020, 2.5f, psycho17_config.mid_gray); - } - color_bt2020 = renodx_custom::tonemap::psycho::ApplyTest17BT2020(color_bt2020, hue_shift_source_bt2020, psycho17_config); - - float3 color_pq = renodx::color::pq::EncodeSafe(color_bt2020, RENODX_DIFFUSE_WHITE_NITS); +float3 GenerateOutputAvatar(float3 ungraded_bt709, float contrast) { + // `pow(c, contrast) * exposure_adjustment` per channel gives an uncapped approximation of vanilla. + // Lower highlight contrast keeps the image from becoming overly harsh while allowing the sun to reach 10,000 nits. + return GenerateOutput(ungraded_bt709, contrast, contrast * 0.681f, 2.f); +} - return color_pq; +float3 GenerateOutputOutlaws(float3 ungraded_bt709, float contrast) { + return GenerateOutput(ungraded_bt709, contrast, contrast * 0.7375f, 1.7f); }