From ef108cd9aae9cd7342fa08838876773df21a8358 Mon Sep 17 00:00:00 2001 From: Tom Conder Date: Sun, 4 Oct 2026 12:33:02 -0500 Subject: [PATCH] Add Filmic, ACES and AgX tone mapping Select the operator with a ToneMapper enum passed to SceneTarget::resolve(). Reinhard stays the default. A Tone Map tab in the debug UI picks it, and the choice is saved as video.toneMapper. --- assets/shaders/slang/tonemap.slang | 61 ++++++++++++++++++- game/src/layer/imgui/imguilayer.cpp | 31 ++++++++++ game/src/layer/imgui/imguilayer.hpp | 1 + game/src/layer/mazelayer.cpp | 15 ++++- game/src/layer/mazelayer.hpp | 20 +++--- game/src/maze.cpp | 8 +++ game/src/thread/mazeframe.hpp | 5 ++ sponge/src/platform/AGENTS.md | 4 ++ .../src/platform/opengl/scene/scenetarget.cpp | 4 +- .../src/platform/opengl/scene/scenetarget.hpp | 13 +++- 10 files changed, 147 insertions(+), 15 deletions(-) diff --git a/assets/shaders/slang/tonemap.slang b/assets/shaders/slang/tonemap.slang index 09c9262b..bd4fce21 100644 --- a/assets/shaders/slang/tonemap.slang +++ b/assets/shaders/slang/tonemap.slang @@ -17,6 +17,56 @@ uniform float bloomIntensity; // runs after it and dithers on its own write, and dithering into an // intermediate float target would just seed noise for TAA to accumulate. uniform bool ditherOutput; +// Must match the order of ToneMapper in scenetarget.hpp. +uniform int toneMapper; + +static const int toneReinhard = 0; +static const int toneFilmic = 1; +static const int toneAces = 2; +static const int toneAgx = 3; + +// Uncharted 2 curve (Hable). Exposure bias 2 and white point 11.2 as in the +// original talk. +float3 hable(float3 x) { + const float a = 0.15, b = 0.50, c = 0.10, d = 0.20, e = 0.02, f = 0.30; + return ((x * (a * x + c * b) + d * e) / (x * (a * x + b) + d * f)) - e / f; +} + +float3 filmic(float3 color) { + return hable(color * 2.0) / hable(float3(11.2)); +} + +// Narkowicz fit of the ACES RRT and ODT. +float3 aces(float3 x) { + return saturate((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14)); +} + +// AgX with the polynomial contrast fit (minimal AgX, Wrensch). Returns linear +// display light, so the shared gamma encode below applies as for the others. +float3 agx(float3 color) { + const float3x3 inset = float3x3( + 0.842479062253094, 0.0784335999999992, 0.0792237451477643, + 0.0423282422610123, 0.878468636469772, 0.0791661274605434, + 0.0423756549057051, 0.0784336, 0.879142973793104); + const float3x3 outset = float3x3( + 1.19687900512017, -0.0980208811401368, -0.0990297440797205, + -0.0528968517574562, 1.15190312990417, -0.0989611768448433, + -0.0529716355144438, -0.0980434501171241, 1.15107367264116); + const float minEv = -12.47393; + const float maxEv = 4.026069; + + float3 v = mul(inset, color); + v = clamp(log2(max(v, float3(1e-10))), minEv, maxEv); + v = (v - minEv) / (maxEv - minEv); + + float3 v2 = v * v; + float3 v4 = v2 * v2; + v = 15.5 * v4 * v2 - 40.14 * v4 * v + 31.96 * v4 - 6.868 * v2 * v + + 0.4298 * v2 + 0.1191 * v - 0.00232; + + v = mul(outset, v); + return pow(max(v, float3(0.0)), float3(2.2)); +} // clang-format off [shader("fragment")] @@ -27,8 +77,15 @@ float4 fragMain(FSInput input, float4 fragCoord : SV_Position) : SV_Target { color += bloomTex.Sample(input.texCoords).rgb * bloomIntensity; } - // Reinhard - color = color / (color + float3(1.0)); + if (toneMapper == toneFilmic) { + color = filmic(color); + } else if (toneMapper == toneAces) { + color = aces(color); + } else if (toneMapper == toneAgx) { + color = agx(color); + } else { + color = color / (color + float3(1.0)); + } color = pow(color, float3(1.0 / 2.2)); diff --git a/game/src/layer/imgui/imguilayer.cpp b/game/src/layer/imgui/imguilayer.cpp index 0cb4755e..882f296e 100644 --- a/game/src/layer/imgui/imguilayer.cpp +++ b/game/src/layer/imgui/imguilayer.cpp @@ -206,6 +206,10 @@ void ImGuiLayer::showLightsSection() { showBloomControls(); ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Tone Map##Tab")) { + showToneMapControls(); + ImGui::EndTabItem(); + } if (ImGui::BeginTabItem("SSAO##Tab")) { showSsaoControls(); ImGui::EndTabItem(); @@ -263,6 +267,33 @@ void ImGuiLayer::showBloomControls() { } } +void ImGuiLayer::showToneMapControls() { + // Order must match ToneMapper. + static constexpr std::array names = { "Reinhard", "Filmic", + "ACES", "AgX" }; + if (ImGui::BeginTable("ToneMap##Table", 2, tableFlags)) { + const auto mazeLayer = Maze::get().getMazeLayer(); + + auto current = static_cast(mazeLayer->getToneMapper()); + showTableRow([&] { + ImGui::Text("Operator"); + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + if (ImGui::Combo("##tonemapper", ¤t, names.data(), + static_cast(names.size()))) { + mazeLayer->setToneMapper( + static_cast( + current)); + sponge::core::Settings::set("video.toneMapper", + static_cast(current)); + sponge::core::Settings::save(); + } + }); + + ImGui::EndTable(); + } +} + void ImGuiLayer::showSsaoControls() { if (ImGui::BeginTable("Ssao##Table", 2, tableFlags)) { const auto mazeLayer = Maze::get().getMazeLayer(); diff --git a/game/src/layer/imgui/imguilayer.hpp b/game/src/layer/imgui/imguilayer.hpp index 87b4c478..91db022d 100644 --- a/game/src/layer/imgui/imguilayer.hpp +++ b/game/src/layer/imgui/imguilayer.hpp @@ -42,6 +42,7 @@ class ImGuiLayer final : public sponge::layer::Layer { static void showMenu(); // App info window sections static void showLightsSection(); static void showBloomControls(); + static void showToneMapControls(); static void showSsaoControls(); static void showSsrControls(); static void showDirectionalLightControls(); diff --git a/game/src/layer/mazelayer.cpp b/game/src/layer/mazelayer.cpp index 0c44817c..81c5b40e 100644 --- a/game/src/layer/mazelayer.cpp +++ b/game/src/layer/mazelayer.cpp @@ -78,6 +78,7 @@ using sponge::platform::opengl::scene::ShadowMap; using sponge::platform::opengl::scene::Ssao; using sponge::platform::opengl::scene::Ssr; using sponge::platform::opengl::scene::TAA; +using sponge::platform::opengl::scene::ToneMapper; using thread::AntiAliasing; MazeLayer::MazeLayer() : @@ -557,6 +558,7 @@ void MazeLayer::captureRenderFrame(const uint32_t slotIndex) { frame.bloomEnabled = bloomEnabled; frame.bloomThreshold = bloomThreshold; frame.bloomIntensity = bloomIntensity; + frame.toneMapper = toneMapper; frame.ssaoEnabled = ssaoEnabled; frame.ssaoRadius = ssaoRadius; @@ -823,8 +825,8 @@ void MazeLayer::onRender() { taa->begin(); } - sceneTarget->resolve(bloomTexId, bloomWeight, - !fxaaActive && !taaActive); + sceneTarget->resolve(bloomTexId, bloomWeight, !fxaaActive && !taaActive, + frame.toneMapper); gpuEnd(); } @@ -1597,6 +1599,15 @@ void MazeLayer::setBloomIntensity(const float val) { bloomIntensity = val; } +ToneMapper MazeLayer::getToneMapper() const { + return toneMapper; +} + +void MazeLayer::setToneMapper(const ToneMapper val) { + std::scoped_lock lock(settingsMutex); + toneMapper = val; +} + bool MazeLayer::isSsaoEnabled() const { return ssaoEnabled; } diff --git a/game/src/layer/mazelayer.hpp b/game/src/layer/mazelayer.hpp index 7623875c..e7b8b8de 100644 --- a/game/src/layer/mazelayer.hpp +++ b/game/src/layer/mazelayer.hpp @@ -120,12 +120,14 @@ class MazeLayer final : public sponge::layer::Layer { void setAntiAliasing(thread::AntiAliasing val); - bool isBloomEnabled() const; - void setBloomEnabled(bool val); - float getBloomThreshold() const; - void setBloomThreshold(float val); - float getBloomIntensity() const; - void setBloomIntensity(float val); + bool isBloomEnabled() const; + void setBloomEnabled(bool val); + float getBloomThreshold() const; + void setBloomThreshold(float val); + float getBloomIntensity() const; + void setBloomIntensity(float val); + sponge::platform::opengl::scene::ToneMapper getToneMapper() const; + void setToneMapper(sponge::platform::opengl::scene::ToneMapper val); bool isSsaoEnabled() const; void setSsaoEnabled(bool val); @@ -342,8 +344,10 @@ class MazeLayer final : public sponge::layer::Layer { float ao = .25F; int32_t attenuationIndex = 4; thread::AntiAliasing antiAliasing = thread::AntiAliasing::Taa; - bool bloomEnabled = true; - float bloomThreshold = 0.8F; + sponge::platform::opengl::scene::ToneMapper toneMapper = + sponge::platform::opengl::scene::ToneMapper::Reinhard; + bool bloomEnabled = true; + float bloomThreshold = 0.8F; // Additive weight for the bloom texture, applied in LINEAR space before // tone mapping. The bloom texture holds radiance (measured peak ~2.6 in // this scene), not the [0,1] tone-mapped values it held when bloom diff --git a/game/src/maze.cpp b/game/src/maze.cpp index f1cc143d..959aa300 100644 --- a/game/src/maze.cpp +++ b/game/src/maze.cpp @@ -68,6 +68,14 @@ bool Maze::onUserCreate() { setBloomEnabled( sponge::core::Settings::getBool("video.bloomEnabled", true)); + using sponge::platform::opengl::scene::ToneMapper; + const auto savedTone = sponge::core::Settings::getUInt32( + "video.toneMapper", static_cast(ToneMapper::Reinhard)); + mazeLayer->setToneMapper(savedTone < + static_cast(ToneMapper::Count) ? + static_cast(savedTone) : + ToneMapper::Reinhard); + // After the settings above, so the maze is configured when it loads. if (captureFrames > 0) { loadingLayer->setActive(true); diff --git a/game/src/thread/mazeframe.hpp b/game/src/thread/mazeframe.hpp index 59ae2237..f2bc8e88 100644 --- a/game/src/thread/mazeframe.hpp +++ b/game/src/thread/mazeframe.hpp @@ -2,6 +2,7 @@ #include "platform/opengl/scene/clusteredlights.hpp" #include "platform/opengl/scene/model.hpp" +#include "platform/opengl/scene/scenetarget.hpp" #include "platform/opengl/scene/shadowmap.hpp" #include "scene/refraction.hpp" @@ -115,6 +116,10 @@ struct MazeRenderFrame { float bloomThreshold{ 0.8F }; float bloomIntensity{ 0.08F }; + sponge::platform::opengl::scene::ToneMapper toneMapper{ + sponge::platform::opengl::scene::ToneMapper::Reinhard + }; + bool ssaoEnabled{ true }; float ssaoRadius{ 0.5F }; diff --git a/sponge/src/platform/AGENTS.md b/sponge/src/platform/AGENTS.md index 54bf79d1..d5d8d67f 100644 --- a/sponge/src/platform/AGENTS.md +++ b/sponge/src/platform/AGENTS.md @@ -119,6 +119,10 @@ than calling GLFW/OpenGL/OS APIs directly. `Ssr` and the glass pass each sample `SceneTarget::copyColor()`, never the color attachment they draw into. The prepass normal alpha is the SSR strength mask. +* resolve() takes a `ToneMapper` (Reinhard, Filmic, ACES, AgX). Every + operator outputs linear display light and shares the one gamma encode, so + AgX includes its own inverse transfer step. Keep `ToneMapper` in + `scenetarget.hpp` in step with the `tone*` constants in `tonemap.slang`. * Dither exactly once, at the final 8-bit write, and keep every intermediate target float (`GL_RGB16F`). An 8-bit intermediate quantises undithered and then quantises again at the real output — the banding `dither8` exists to diff --git a/sponge/src/platform/opengl/scene/scenetarget.cpp b/sponge/src/platform/opengl/scene/scenetarget.cpp index 9b863421..abdabc9c 100644 --- a/sponge/src/platform/opengl/scene/scenetarget.cpp +++ b/sponge/src/platform/opengl/scene/scenetarget.cpp @@ -104,7 +104,8 @@ void SceneTarget::blitDepthTo(const uint32_t destFbo, const int w, } void SceneTarget::resolve(const uint32_t bloomTexId, const float bloomIntensity, - const bool ditherOutput) const { + const bool ditherOutput, + const ToneMapper toneMapper) const { // Depth testing would discard the full-screen quad behind whatever the // scene pass left in the depth buffer. glDisable(GL_DEPTH_TEST); @@ -112,6 +113,7 @@ void SceneTarget::resolve(const uint32_t bloomTexId, const float bloomIntensity, shader->bind(); shader->setFloat("bloomIntensity", bloomIntensity); shader->setBoolean("ditherOutput", ditherOutput); + shader->setInteger("toneMapper", static_cast(toneMapper)); glBindTextureUnit(0, colorTexture); glBindTextureUnit(1, bloomTexId); diff --git a/sponge/src/platform/opengl/scene/scenetarget.hpp b/sponge/src/platform/opengl/scene/scenetarget.hpp index 4f80b88d..08defb07 100644 --- a/sponge/src/platform/opengl/scene/scenetarget.hpp +++ b/sponge/src/platform/opengl/scene/scenetarget.hpp @@ -9,6 +9,15 @@ namespace sponge::platform::opengl::scene { +// Order must match the `tone*` constants in tonemap.slang. +enum class ToneMapper : uint8_t { + Reinhard = 0, + Filmic, + Aces, + Agx, + Count, +}; + // The linear HDR buffer the scene renders into, and the pass that turns it // into a display image. // @@ -57,8 +66,8 @@ class SceneTarget { // `bloomIntensity` is 0. Set `ditherOutput` only when this pass writes the // final 8-bit target — anti-aliasing, when enabled, runs afterwards and // dithers on its own write. - void resolve(uint32_t bloomTexId, float bloomIntensity, - bool ditherOutput) const; + void resolve(uint32_t bloomTexId, float bloomIntensity, bool ditherOutput, + ToneMapper toneMapper) const; void resize(uint32_t newWidth, uint32_t newHeight);