Add a new tone mapper (#9614)

The new tone mapper, dubbed "GT7" in the code, is based on
the Gran Turismo 7 tone mapper, as described in the SIGGRAPH
2025 presentation called "Driving Toward Reality: Physically
Based Tone Mapping and Perceptual Fidelity in Gran Turismo 7".

This tone mapper exhibits fewer hue skews than the other tone
mappers, at the exception of PBR Neutral. The GT7 tone mapper
is however better at preserving the perception of high
dynamic range.

The tone mapper, as implemented, targets an SDR paper white
of 250 nits, using 100 cd/m^2 as the reference luminance (for
values of 1.0 in the linear HDR framebuffer). This can result
in an overall darker image compared to the other tone mappers
but this can be controlled through camera settings, post-
processing exposure, or lighting intensity.

The GT7 tone mapper also allows to target HDR output, and
could be made customizable via APIs if desired. The current
implementation offers a fixed aesthetic solution.
This commit is contained in:
Romain Guy
2026-01-21 17:31:27 -08:00
committed by GitHub
parent 7266fd67db
commit 73b60d4db8
8 changed files with 305 additions and 45 deletions

View File

@@ -52,6 +52,11 @@ Java_com_google_android_filament_ToneMapper_nCreatePBRNeutralToneMapper(JNIEnv*,
return (jlong) new PBRNeutralToneMapper();
}
extern "C" JNIEXPORT jlong JNICALL
Java_com_google_android_filament_ToneMapper_nCreateGT7ToneMapper(JNIEnv*, jclass) {
return (jlong) new GT7ToneMapper();
}
extern "C" JNIEXPORT jlong JNICALL
Java_com_google_android_filament_ToneMapper_nCreateAgxToneMapper(JNIEnv*, jclass, jint look) {
return (jlong) new AgxToneMapper(AgxToneMapper::AgxLook(look));

View File

@@ -24,6 +24,7 @@ package com.google.android.filament;
* <li>ACESLegacyToneMapper</li>
* <li>FilmicToneMapper</li>
* <li>PBRNeutralToneMapper</li>
* <li>GT7ToneMapper</li>
* </ul>
* <li>Debug/validation tone mapping operators</li>
* <ul>
@@ -111,6 +112,19 @@ public class ToneMapper {
}
}
/**
* Gran Turismo 7 tone mapping operator. This tone mapper was designed
* to preserve the appearance of materials across lighting conditions while
* avoiding artifacts in the highlights in high dynamic range conditions.
* This tone mapper targets an SDR paper white value of 250 nits, with a
* reference luminance of 100 cd/m^2 (a value of 1.0 in the HDR framebuffer).
*/
public static class GT7ToneMapper extends ToneMapper {
public GT7ToneMapper() {
super(nCreateGT7ToneMapper());
}
}
/**
* AgX tone mapping operator.
*/
@@ -244,6 +258,7 @@ public class ToneMapper {
private static native long nCreateACESLegacyToneMapper();
private static native long nCreateFilmicToneMapper();
private static native long nCreatePBRNeutralToneMapper();
private static native long nCreateGT7ToneMapper();
private static native long nCreateAgxToneMapper(int look);
private static native long nCreateGenericToneMapper(
float contrast, float midGrayIn, float midGrayOut, float hdrMax);

View File

@@ -45,6 +45,7 @@ namespace filament {
* - ACESLegacyToneMapper
* - FilmicToneMapper
* - PBRNeutralToneMapper
* - GT7ToneMapper
* - Debug/validation tone mapping operators
* - LinearToneMapper
* - DisplayRangeToneMapper
@@ -72,15 +73,17 @@ struct UTILS_PUBLIC ToneMapper {
/**
* If true, then this function holds that f(x) = vec3(f(x.r), f(x.g), f(x.b))
*
* This may be used to indicate that the color grading's LUT only requires a 1D texture instead
* of a 3D texture, potentially saving a significant amount of memory and generation time.
* This may be used to indicate that the color grading's LUT only requires a
* 1D texture instead of a 3D texture, potentially saving a significant amount
* of memory and generation time.
*/
virtual bool isOneDimensional() const noexcept { return false; }
/**
* True if this tonemapper only works in low-dynamic-range.
*
* This may be used to indicate that the color grading's LUT doesn't need to be log encoded.
* This may be used to indicate that the color grading's LUT doesn't need to be
* log encoded.
*/
virtual bool isLDR() const noexcept { return false; }
};
@@ -156,6 +159,26 @@ struct UTILS_PUBLIC PBRNeutralToneMapper final : public ToneMapper {
bool isLDR() const noexcept override { return false; }
};
/**
* Gran Turismo 7 tone mapping operator. This tone mapper was designed
* to preserve the appearance of materials across lighting conditions while
* avoiding artifacts in the highlights in high dynamic range conditions.
* This tone mapper targets an SDR paper white value of 250 nits, with a
* reference luminance of 100 cd/m^2 (a value of 1.0 in the HDR framebuffer).
*/
struct UTILS_PUBLIC GT7ToneMapper final : public ToneMapper {
GT7ToneMapper() noexcept;
~GT7ToneMapper() noexcept final;
math::float3 operator()(math::float3 x) const noexcept override;
bool isOneDimensional() const noexcept override { return false; }
bool isLDR() const noexcept override { return false; }
private:
struct State;
State* mState;
};
/**
* AgX tone mapping operator.
*/

View File

@@ -218,39 +218,6 @@ inline constexpr float chromaticityCoordinateIlluminantD(float const x) noexcept
return 2.87f * x - 3.0f * x * x - 0.275f;
}
//------------------------------------------------------------------------------
// Color space conversions
//------------------------------------------------------------------------------
inline constexpr XYZ xyY_to_XYZ(xyY const v) noexcept {
const float a = v.z / max(v.y, 1e-5f);
return XYZ{v.x * a, v.z, (1.0f - v.x - v.y) * a};
}
inline constexpr xyY XYZ_to_xyY(XYZ v) noexcept {
return {v.xy / max(v.x + v.y + v.z, 1e-5f), v.y};
}
inline constexpr float3 pow3(float3 const x) noexcept {
return x * x * x;
}
inline float3 sRGB_to_OkLab(float3 const x) noexcept {
return OkLab_LMS_to_OkLab * cbrt(sRGB_to_OkLab_LMS * x);
}
inline float3 Rec2020_to_OkLab(float3 const x) noexcept {
return OkLab_LMS_to_OkLab * cbrt(Rec2020_to_OkLab_LMS * x);
}
inline float3 OkLab_to_sRGB(float3 const x) noexcept {
return OkLab_LMS_to_sRGB * pow3(OkLab_to_OkLab_LMS * x);
}
inline float3 OkLab_to_Rec2020(float3 const x) noexcept {
return OkLab_LMS_to_Rec2020 * pow3(OkLab_to_OkLab_LMS * x);
}
//------------------------------------------------------------------------------
// Conversion functions and encoding/decoding
//------------------------------------------------------------------------------
@@ -322,34 +289,95 @@ inline float3 EOTF_sRGB(float3 x) noexcept {
return x;
}
inline float3 OETF_PQ(float3 x, float maxPqValue) {
inline float3 OETF_PQ(float3 x) {
constexpr float PQ_constant_N = 2610.0f / 4096.0f / 4.0f;
constexpr float PQ_constant_M = 2523.0f / 4096.0f * 128.0f;
constexpr float PQ_constant_C1 = 3424.0f / 4096.0f;
constexpr float PQ_constant_C2 = 2413.0f / 4096.0f * 32.0f;
constexpr float PQ_constant_C3 = 2392.0f / 4096.0f * 32.0f;
constexpr float PQ_C = 10000.0f;
x /= maxPqValue;
float3 g = pow(x, PQ_constant_N);
float3 g = pow(x / PQ_C, PQ_constant_N);
float3 numerator = PQ_constant_C1 + PQ_constant_C2 * g;
float3 denominator = 1.0f + PQ_constant_C3 * g;
return pow(numerator / denominator, PQ_constant_M);
}
inline float3 EOTF_PQ(float3 x, float maxPqValue) {
inline float3 EOTF_PQ(float3 x) {
constexpr float PQ_constant_N = 2610.0f / 4096.0f / 4.0f;
constexpr float PQ_constant_M = 2523.0f / 4096.0f * 128.0f;
constexpr float PQ_constant_C1 = 3424.0f / 4096.0f;
constexpr float PQ_constant_C2 = 2413.0f / 4096.0f * 32.0f;
constexpr float PQ_constant_C3 = 2392.0f / 4096.0f * 32.0f;
constexpr float PQ_C = 10000.0f;
float3 g = pow(x, 1.0f / PQ_constant_M);
float3 g = pow(saturate(x), 1.0f / PQ_constant_M);
float3 numerator = max(g - PQ_constant_C1, 0.0f);
float3 denominator = PQ_constant_C2 - (PQ_constant_C3 * g);
float3 linearColor = pow(numerator / denominator, 1.0f / PQ_constant_N);
return linearColor * maxPqValue;
return linearColor * PQ_C;
}
//------------------------------------------------------------------------------
// Color space conversions
//------------------------------------------------------------------------------
inline constexpr XYZ xyY_to_XYZ(xyY const v) noexcept {
const float a = v.z / max(v.y, 1e-5f);
return XYZ{v.x * a, v.z, (1.0f - v.x - v.y) * a};
}
inline constexpr xyY XYZ_to_xyY(XYZ v) noexcept {
return {v.xy / max(v.x + v.y + v.z, 1e-5f), v.y};
}
inline constexpr float3 pow3(float3 const x) noexcept {
return x * x * x;
}
inline float3 sRGB_to_OkLab(float3 const x) noexcept {
return OkLab_LMS_to_OkLab * cbrt(sRGB_to_OkLab_LMS * x);
}
inline float3 Rec2020_to_OkLab(float3 const x) noexcept {
return OkLab_LMS_to_OkLab * cbrt(Rec2020_to_OkLab_LMS * x);
}
inline float3 OkLab_to_sRGB(float3 const x) noexcept {
return OkLab_LMS_to_sRGB * pow3(OkLab_to_OkLab_LMS * x);
}
inline float3 OkLab_to_Rec2020(float3 const x) noexcept {
return OkLab_LMS_to_Rec2020 * pow3(OkLab_to_OkLab_LMS * x);
}
inline float3 Rec2020_to_ICtCp(float3 const x) noexcept {
const float3 lmsPQ = OETF_PQ(float3{
(x.r * 1688.0f + x.g * 2146.0f + x.b * 262.0f) / 4096.0f,
(x.r * 683.0f + x.g * 2951.0f + x.b * 462.0f) / 4096.0f,
(x.r * 99.0f + x.g * 309.0f + x.b * 3688.0f) / 4096.0f
});
return float3{
(2048.0f * lmsPQ.x + 2048.0f * lmsPQ.y) / 4096.0f,
(6610.0f * lmsPQ.x - 13613.0f * lmsPQ.y + 7003.0f * lmsPQ.z) / 4096.0f,
(17933.0f * lmsPQ.x - 17390.0f * lmsPQ.y - 543.0f * lmsPQ.z) / 4096.0f
};
}
inline float3 ICtCp_to_Rec2020(float3 const x) noexcept {
const float3 linear = EOTF_PQ(float3{
x.r + 0.00860904f * x.g + 0.11103f * x.b,
x.r - 0.00860904f * x.g - 0.11103f * x.b,
x.r + 0.560031f * x.g - 0.320627f * x.b
});
return float3{
std::max(3.43661f * linear.x - 2.50645f * linear.y + 0.0698454f * linear.z, 0.0f),
std::max(-0.79133f * linear.x + 1.9836f * linear.y - 0.192271f * linear.z, 0.0f),
std::max(-0.0259499f * linear.x - 0.0989137f * linear.y + 1.12486f * linear.z, 0.0f)
};
}
//------------------------------------------------------------------------------

View File

@@ -256,6 +256,188 @@ float3 PBRNeutralToneMapper::operator()(float3 color) const noexcept {
return mix(color, float3(newPeak), g);
}
//------------------------------------------------------------------------------
// GT7 tone mapper
//------------------------------------------------------------------------------
// The following implementation is based on code provided by Polyphony Digital,
// under the following license:
//
// MIT License
//
// Copyright (c) 2025 Polyphony Digital Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
// See "Driving Toward Reality: Physically Based Tone Mapping and Perceptual
// Fidelity in Gran Turismo 7", SIGGRAPH 2025, by Yasutomi, Suzuki, and Uchimura.
constexpr float ReferenceLuminance = 100.0f; // 100 cd/m^2 equals a value of 1 in the framebuffer
constexpr float SdrPaperWhite = 250.0f; // Paper white target of 250 nits
inline float frameBufferValueToPhysicalValue(float v) {
// Converts a linear framebuffer value to physical luminance (cd/m^2)
// where 1.0 corresponds to the reference luminance.
return v * ReferenceLuminance;
}
inline float3 frameBufferValueToPhysicalValue(float3 v) {
return v * ReferenceLuminance;
}
inline float physicalValueToFrameBufferValue(float v) {
// Converts a physical luminance (cd/m^2) to a linear framebuffer value,
// where 1.0 corresponds to the reference luminance.
return v / ReferenceLuminance;
}
inline float3 physicalValueToFrameBufferValue(float3 v) {
return v / ReferenceLuminance;
}
class GT7Curve {
public:
float evaluate(const float x) const {
if (x < 0.0f) return 0.0f;
const float weightLinear = smoothstep(0.0f, mMidPoint, x);
const float weightToe = 1.0f - weightLinear;
// Shoulder mapping for highlights.
const float shoulder = mKa + mKb * std::expf(x * mKc);
if (x < mLinearSection * mPeakIntensity) {
const float toeMapped = mMidPoint * std::powf(x / mMidPoint, mToeStrength);
return weightToe * toeMapped + weightLinear * x;
}
return shoulder;
}
void initialize(
const float displayIntensity,
const float alpha,
const float grayPoint,
const float linearSection,
const float toeStrength
) {
mPeakIntensity = displayIntensity;
mAlpha = alpha;
mMidPoint = grayPoint;
mLinearSection = linearSection;
mToeStrength = toeStrength;
// Pre-compute constants for the shoulder region.
const float k = (mLinearSection - 1.0f) / (mAlpha - 1.0f);
mKa = mPeakIntensity * mLinearSection + mPeakIntensity * k;
mKb = -mPeakIntensity * k * std::expf(mLinearSection / k);
mKc = -1.0f / (k * mPeakIntensity);
}
private:
float mPeakIntensity{};
float mAlpha{};
float mMidPoint{};
float mLinearSection{};
float mToeStrength{};
float mKa{};
float mKb{};
float mKc{};
};
struct GT7ToneMapper::State {
float sdrCorrectionFactor;
float framebufferLuminanceTarget;
float framebufferLuminanceTargetUcs;
float blendRatio;
float fadeStart;
float fadeEnd;
GT7Curve curve;
// The display target luminance should be ~250 nits for SDR displays,
// and whatever peak luminance an HDR display supports (700, 1000, etc.).
void initializeParameters(float sdrCorrection, float displayTargetLuminance) {
sdrCorrectionFactor = sdrCorrection;
framebufferLuminanceTarget = physicalValueToFrameBufferValue(displayTargetLuminance);
// TODO: We could expose the curve parameters to users
curve.initialize(framebufferLuminanceTarget, 0.25f, 0.538f, 0.444f, 1.280f);
// TODO: Expose these controls to the user
blendRatio = 0.6f;
fadeStart = 0.98f;
fadeEnd = 1.16f;
const float3 rgb{framebufferLuminanceTarget};
framebufferLuminanceTargetUcs = Rec2020_to_ICtCp(frameBufferValueToPhysicalValue(rgb)).x;
}
};
GT7ToneMapper::GT7ToneMapper() noexcept {
mState = new State();
// Initialize for an SDR target
mState->initializeParameters(
1.0f / physicalValueToFrameBufferValue(SdrPaperWhite),
SdrPaperWhite
);
// TODO: To initialize for HDR output, pass 1.0 as the SDR correction factor,
// and the desired peak display luminance as the second parameter
}
GT7ToneMapper::~GT7ToneMapper() noexcept {
delete mState;
}
inline float chromaCurve(float a, float b, float x) {
return 1.0f - smoothstep(a, b, x);
}
float3 GT7ToneMapper::operator()(float3 color) const noexcept {
const State& state = *mState;
const GT7Curve& curve = state.curve;
float3 ucs = Rec2020_to_ICtCp(frameBufferValueToPhysicalValue(color));
float3 skewedRgb{
curve.evaluate(color.r),
curve.evaluate(color.g),
curve.evaluate(color.b)
};
float3 skewedUcs = Rec2020_to_ICtCp(frameBufferValueToPhysicalValue(skewedRgb));
float chromaScale = chromaCurve(
state.fadeStart, state.fadeEnd, ucs.x / state.framebufferLuminanceTargetUcs);
float3 scaledRgb = physicalValueToFrameBufferValue(ICtCp_to_Rec2020(float3{
skewedUcs.x,
ucs.y * chromaScale,
ucs.z * chromaScale
}));
const float blendRatio = state.blendRatio;
const float sdrFactor = state.sdrCorrectionFactor;
float3 luminanceTarget{state.framebufferLuminanceTarget};
return sdrFactor * min(mix(skewedRgb, scaledRgb, blendRatio), luminanceTarget);
}
//------------------------------------------------------------------------------
// AgX tone mapper
//------------------------------------------------------------------------------

View File

@@ -61,7 +61,8 @@ enum class ToneMapping : uint8_t {
AGX = 4,
GENERIC = 5,
PBR_NEUTRAL = 6,
DISPLAY_RANGE = 7,
GT7 = 7,
DISPLAY_RANGE = 8,
};
using AmbientOcclusionOptions = filament::View::AmbientOcclusionOptions;

View File

@@ -88,6 +88,7 @@ static int parse(jsmntok_t const* tokens, int i, const char* jsonChunk, ToneMapp
else if (0 == compare(tokens[i], jsonChunk, "AGX")) { *out = ToneMapping::AGX; }
else if (0 == compare(tokens[i], jsonChunk, "GENERIC")) { *out = ToneMapping::GENERIC; }
else if (0 == compare(tokens[i], jsonChunk, "PBR_NEUTRAL")) { *out = ToneMapping::PBR_NEUTRAL; }
else if (0 == compare(tokens[i], jsonChunk, "GT7")) { *out = ToneMapping::GT7; }
else if (0 == compare(tokens[i], jsonChunk, "DISPLAY_RANGE")) { *out = ToneMapping::DISPLAY_RANGE; }
else {
slog.w << "Invalid ToneMapping: '" << STR(tokens[i], jsonChunk) << "'" << io::endl;
@@ -722,6 +723,7 @@ constexpr ToneMapper* createToneMapper(const ColorGradingSettings& settings) noe
settings.genericToneMapper.hdrMax
);
case ToneMapping::PBR_NEUTRAL: return new PBRNeutralToneMapper;
case ToneMapping::GT7: return new GT7ToneMapper;
case ToneMapping::DISPLAY_RANGE: return new DisplayRangeToneMapper;
}
}
@@ -773,6 +775,7 @@ static std::ostream& operator<<(std::ostream& out, ToneMapping in) {
case ToneMapping::AGX: return out << "\"AGX\"";
case ToneMapping::GENERIC: return out << "\"GENERIC\"";
case ToneMapping::PBR_NEUTRAL: return out << "\"PBR_NEUTRAL\"";
case ToneMapping::GT7: return out << "\"GT7\"";
case ToneMapping::DISPLAY_RANGE: return out << "\"DISPLAY_RANGE\"";
}
return out << "\"INVALID\"";

View File

@@ -178,6 +178,9 @@ static void computeToneMapPlot(ColorGradingSettings& settings, float* plot) {
case ToneMapping::PBR_NEUTRAL:
mapper = new PBRNeutralToneMapper;
break;
case ToneMapping::GT7:
mapper = new GT7ToneMapper;
break;
case ToneMapping::DISPLAY_RANGE:
mapper = new DisplayRangeToneMapper;
break;
@@ -229,7 +232,7 @@ static void colorGradingUI(Settings& settings, float* rangePlot, float* curvePlo
int toneMapping = (int) colorGrading.toneMapping;
ImGui::Combo("Tone-mapping", &toneMapping,
"Linear\0ACES (legacy)\0ACES\0Filmic\0AgX\0Generic\0PBR Neutral\0Display Range\0\0");
"Linear\0ACES (legacy)\0ACES\0Filmic\0AgX\0Generic\0PBR Neutral\0GT7\0Display Range\0\0");
colorGrading.toneMapping = (decltype(colorGrading.toneMapping)) toneMapping;
if (colorGrading.toneMapping == ToneMapping::GENERIC) {
if (ImGui::CollapsingHeader("Tonemap parameters")) {