From 87677f9c4f3e1f25000f5a4914445571ea6b4574 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 22 Aug 2026 23:10:57 +0200 Subject: [PATCH] feat(render): Atmospheric post stack runs in linear light; neutral preset is numerically the pack-off image (Campaign VM VM3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes review finding F4 (docs/research/2026-08-22-campaign-ar-review.md): retail's main-world colour, sun rays and volumetric shafts are all gamma-encoded display-space values (the 2013 client has no linear lighting pipeline), but bloom thresholding, ACES (Narkowicz fit), Rec.709 luma saturation, the contrast pivot and the vignette were all operating directly on those gamma values, then writing the result to the UNORM swapchain without re-encoding. - atmospheric_common.glsl gains acdreamDecodeDisplay/acdreamEncodeDisplay (pow(c, 2.2) / pow(c, 1/2.2)). 2.2 is the retail-era CRT/early-LCD display-gamma assumption, deliberately not the sRGB piecewise curve, which would claim a precision retail's authoring pipeline never had. uAtmosphereSunColor's comment is corrected from "authored linear rgb" to "authored display-space rgb (retail has no linear pipeline)". - atmospheric_bloom_downsample.frag, atmospheric_filmic.frag (both the fused-Low and non-fused paths) decode every world/ray/volumetric read before summing/thresholding; atmospheric_bloom_blur.frag is unchanged (it already reads the now-linear bloom buffer); atmospheric_sun_rays.frag and atmospheric_volumetric.frag are documented as writing display-space colour that the consumers decode. - The contrast pivot moves from 0.5 (a gamma-space midpoint) to 0.18 (linear mid-grey, the standard 18%-grey-card exposure convention). The final filmic output is clamped in linear, then re-encoded before the UNORM write. - Bloom threshold/knee are re-derived for linear light: the pre-VM3 gamma-space pair was threshold 1.0 / knee 0.45, i.e. a soft range of [0.55, 1.0] in gamma. Decoding both ends with the same 2.2 assumption gives decode(1.0) = 1.0 (threshold unchanged) and decode(0.55) = 0.55^2.2 ~= 0.27, so linear knee = 1.0 - 0.27 ~= 0.73. Replaced the inline 0.45f literals with named constants BloomThresholdLinear = 1f / BloomKneeLinear = 0.73f on AtmosphericPostProcessGraph. bloom-strength's 0.65 default is untouched. - Exposure stays at its accepted 0.80 default: in linear, encode(acesFitted(0.80 * decode(0.46))) ~= 0.50, reproducing the same accepted midtone the old gamma-space pipeline produced as 0.51 for the same 0.46 input (0.46 * 0.80 fed straight into acesFitted, no decode/encode). Highlights now retain more (gamma 0.9 input moves from ~0.74 to ~0.85 through the full pipeline) and blacks deepen slightly (gamma 0.1 moves from ~0.09 to ~0.05) — the owner's visual gate judges. - Added AtmosphericColorPipeline, a CPU mirror of the GLSL decode/encode/ ACES/grade/filmic math (line-for-line, with a header comment requiring it stay mirrored), and AtmosphericColorPipelineTests: neutral-preset identity within half an 8-bit step for a 0..255 grey sweep (proving the neutral preset is numerically the pack-off image), decode/encode round-trip within 1e-6, monotonic-in-exposure, the pinned midtone/ highlight/shadow numbers above, and the bloom-knee derivation. - Added a shader-source pinning test so a future edit cannot silently drop the colour-space conversions: atmospheric_filmic.frag must contain exactly one acdreamEncodeDisplay( call in main()'s output, atmospheric_bloom_downsample.frag must contain at least three acdreamDecodeDisplay( calls. - Regenerated SPIR-V (tools/compile-shaders.ps1, glslc from the installed Vulkan SDK). Only atmospheric_bloom_downsample.frag.spv and atmospheric_filmic.frag.spv changed in bytes; every other pack shader that includes atmospheric_common.glsl recompiled to a byte-identical binary (the new decode/encode helpers are unreferenced dead code for them). VulkanShaderManifestTests' retail-oracle SHA-256 set (mesh_modern, terrain_modern, mesh_detail, etc.) is untouched and still passes — the retail default path did not change. - Docs: noted the linear-light move in the AR plan's Slice 1 section, and added a "Colour space" section to the render-pack ABI doc (docs/render-packs/semantic-bindings-v1.md) naming which inputs are display-space and pointing at atmospheric_common.glsl as the reference implementation. No ABI version bump — the binding layout is unchanged. Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-08-21-atmospheric-rendering.md | 5 + docs/render-packs/semantic-bindings-v1.md | 18 ++- .../Packs/AtmosphericColorPipeline.cs | 103 +++++++++++++ .../Packs/AtmosphericPostProcessGraph.cs | 20 ++- .../Packs/BuiltInAtmosphericRenderPack.cs | 11 ++ .../Shaders/atmospheric_bloom_blur.frag | 3 + .../Shaders/atmospheric_bloom_downsample.frag | 10 +- .../Rendering/Shaders/atmospheric_common.glsl | 21 ++- .../Rendering/Shaders/atmospheric_filmic.frag | 32 +++- .../Shaders/atmospheric_sun_rays.frag | 3 + .../Shaders/atmospheric_volumetric.frag | 3 + .../spv/atmospheric_bloom_downsample.frag.spv | Bin 2720 -> 2952 bytes .../Shaders/spv/atmospheric_filmic.frag.spv | Bin 7016 -> 7672 bytes .../Shaders/spv/shaders.manifest.json | 12 +- .../Packs/AtmosphericColorPipelineTests.cs | 143 ++++++++++++++++++ .../Packs/AtmosphericPostProcessGraphTests.cs | 50 +++++- 16 files changed, 410 insertions(+), 24 deletions(-) create mode 100644 src/AcDream.App/Rendering/Packs/AtmosphericColorPipeline.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Packs/AtmosphericColorPipelineTests.cs diff --git a/docs/plans/2026-08-21-atmospheric-rendering.md b/docs/plans/2026-08-21-atmospheric-rendering.md index 65e20103..05efa064 100644 --- a/docs/plans/2026-08-21-atmospheric-rendering.md +++ b/docs/plans/2026-08-21-atmospheric-rendering.md @@ -574,6 +574,11 @@ appraisal surfaces retain their accepted colours; 1080p/1440p/4K captures show no clipping, haloing at the world/UI edge, stale frame, or resource leak; the slice meets its preset GPU/VRAM budget. +Campaign VM VM3 (2026-08-22) moved the post stack to linear light: every +world/ray/volumetric/bloom read is decoded with the 2.2 display assumption, +tonemap/grade/vignette run in linear, and the result is re-encoded; the +neutral preset is numerically the pack-off image. + ### Slice 2 — Tier-1 screen-space sun rays **Implementation:** complete. Authored sun projection, the screen-space diff --git a/docs/render-packs/semantic-bindings-v1.md b/docs/render-packs/semantic-bindings-v1.md index 2d0b1dd6..4889e805 100644 --- a/docs/render-packs/semantic-bindings-v1.md +++ b/docs/render-packs/semantic-bindings-v1.md @@ -92,7 +92,7 @@ below state the same values for review and tool diagnostics. ```glsl layout(std140, set = 3, binding = 5) uniform AtmosphericFrame { vec4 uAtmosphereSunScreen; // @0: uv.xy, resolved ray strength, elevation degrees - vec4 uAtmosphereSunColor; // @16: linear rgb, combined ray-policy multiplier + vec4 uAtmosphereSunColor; // @16: authored display-space rgb (retail has no linear pipeline), combined ray-policy multiplier vec4 uAtmosphereViewport; // @32: width, height, 1/width, 1/height vec4 uAtmosphereWeather; // @48: WeatherKind numeric, intensity, delta seconds, outdoor 0/1 vec4 uAtmosphereSunDirection; // @64: surface-to-sun xyz, authored direction brightness @@ -134,6 +134,22 @@ curve without an exact 0-degree point must make its first positive point zero. from scene depth and the normalized viewport coordinates. Matrix convention and depth range match the shared push-block `viewProjection`. +### Colour space + +The main-world colour target, `uAtmosphereSunColor` (sun-ray input), and any +pack-written sun-ray or volumetric-shaft colour are all **display-space** +(retail's 2013 client has no linear lighting pipeline — its fixed-function +output is gamma-encoded for direct display). A pack that does linear-space +math — bloom thresholding, ACES or another filmic tonemap, luma-weighted +saturation, a contrast pivot — must decode each such input before that math +and encode its final output before writing to the UNORM swapchain, or the +math is operating on the wrong numbers (Campaign VM VM3, closing finding F4 of +the Campaign AR review). The built-in Atmospheric pack's +`acdreamDecodeDisplay`/`acdreamEncodeDisplay` helpers in +`atmospheric_common.glsl` (`pow(c, 2.2)` / `pow(c, 1/2.2)`) are the reference +implementation; 2.2 is the retail-era display-gamma assumption, deliberately +not the sRGB piecewise curve. + ### `PackPass` — set 3, binding 7, 64 bytes ```glsl diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericColorPipeline.cs b/src/AcDream.App/Rendering/Packs/AtmosphericColorPipeline.cs new file mode 100644 index 00000000..f287604d --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/AtmosphericColorPipeline.cs @@ -0,0 +1,103 @@ +using System.Numerics; + +namespace AcDream.App.Rendering.Packs; + +/// +/// CPU-side mirror of the linear-light colour math added by Campaign VM VM3: +/// acdreamDecodeDisplay/acdreamEncodeDisplay in +/// atmospheric_common.glsl, and the exposure -> ACES -> grade -> +/// vignette chain in atmospheric_filmic.frag's main(). +/// +/// The GLSL is the source of truth for what actually renders — this +/// class exists only so the neutral-preset identity and the accepted +/// midtone/knee derivations can be pinned by fast CPU tests +/// (AtmosphericColorPipelineTests) instead of a GPU capture. Any +/// change to the mirrored GLSL functions — the 2.2 display-gamma exponent, +/// the ACES fitted curve constants, the Rec.709 luma weights, the 0.18 +/// linear-mid-grey contrast pivot, or the exposure/mix/grade/vignette order — +/// MUST be mirrored here in the same commit, or this class silently stops +/// proving what the shader does. +/// +internal static class AtmosphericColorPipeline +{ + /// + /// 2.2 is the retail-era display-gamma assumption (the 2013 client was + /// authored for CRT/early-LCD gamma 2.2, not the sRGB piecewise curve, + /// which would claim a precision the source never had). See + /// atmospheric_common.glsl. + /// + internal const float DisplayGamma = 2.2f; + + /// + /// Linear mid-grey contrast pivot (the standard 18%-grey-card exposure + /// convention). See the LinearMidGrey constant in + /// atmospheric_filmic.frag. + /// + internal const float LinearMidGrey = 0.18f; + + private static readonly Vector3 Rec709Luma = new(0.2126f, 0.7152f, 0.0722f); + + /// Mirrors acdreamDecodeDisplay: display-space to linear light. + internal static Vector3 Decode(Vector3 c) => + Pow(Vector3.Max(c, Vector3.Zero), DisplayGamma); + + /// Mirrors acdreamEncodeDisplay: linear light to display-space. + internal static Vector3 Encode(Vector3 c) => + Pow(Vector3.Max(c, Vector3.Zero), 1f / DisplayGamma); + + /// Mirrors acesFitted (Narkowicz fit) in atmospheric_filmic.frag. + internal static Vector3 AcesFitted(Vector3 value) + { + const float a = 2.51f; + const float b = 0.03f; + const float c = 2.43f; + const float d = 0.59f; + const float e = 0.14f; + Vector3 numerator = value * (a * value + new Vector3(b)); + Vector3 denominator = value * (c * value + new Vector3(d)) + new Vector3(e); + return Vector3.Clamp(numerator / denominator, Vector3.Zero, Vector3.One); + } + + /// + /// Mirrors the saturation (Rec.709 luma mix) and contrast (0.18 linear + /// pivot) steps in atmospheric_filmic.frag's main(). + /// + internal static Vector3 Grade(Vector3 color, float saturation, float contrast) + { + float luminance = Vector3.Dot(color, Rec709Luma); + color = Vector3.Lerp(new Vector3(luminance), color, saturation); + return (color - new Vector3(LinearMidGrey)) * contrast + new Vector3(LinearMidGrey); + } + + /// + /// Mirrors atmospheric_filmic.frag's main() from the exposure + /// multiply through the vignette multiply and the final clamp — i.e. + /// everything downstream of decode and everything upstream of encode. + /// is the already-resolved per-pixel + /// multiplier mix(1.0, vignette, clamp(vignetteStrength, 0, 1)); + /// pass 1.0 for "no vignetting" (screen centre, or vignette strength 0). + /// + internal static Vector3 Filmic( + Vector3 hdr, + float exposure, + float filmicStrength, + float saturation, + float contrast, + float vignetteFactor) + { + Vector3 exposed = Vector3.Max(hdr * exposure, Vector3.Zero); + Vector3 linearClamped = Vector3.Clamp(exposed, Vector3.Zero, Vector3.One); + Vector3 color = Vector3.Lerp( + linearClamped, + AcesFitted(exposed), + Math.Clamp(filmicStrength, 0f, 1f)); + color = Grade(color, saturation, contrast); + color *= vignetteFactor; + return Vector3.Clamp(color, Vector3.Zero, Vector3.One); + } + + private static Vector3 Pow(Vector3 value, float exponent) => new( + MathF.Pow(value.X, exponent), + MathF.Pow(value.Y, exponent), + MathF.Pow(value.Z, exponent)); +} diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs index 1009ff77..94d1a2e1 100644 --- a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs +++ b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs @@ -129,6 +129,18 @@ internal sealed class AtmosphericPostProcessGraph : private bool _renderedFrame; private bool _disposed; + // Campaign VM VM3: bloom threshold/knee, re-derived for the linear-light + // post stack (see atmospheric_common.glsl's acdreamDecodeDisplay). The + // pre-VM3 gamma-space pair was threshold 1.0 / knee 0.45, i.e. a soft + // bloom range of [0.55, 1.0] in gamma-encoded display values. Decoding + // both ends with the same 2.2 assumption gives the equivalent linear + // range: decode(1.0) = 1.0 (threshold is unchanged — 1.0 is a fixed + // point of pow(x, 2.2)), decode(0.55) = 0.55^2.2 ~= 0.27, so the linear + // knee is threshold - lowerBound = 1.0 - 0.27 ~= 0.73. Same set of + // pixels blooms; the math now runs in the space ACES/luma assume. + internal const float BloomThresholdLinear = 1f; + internal const float BloomKneeLinear = 0.73f; + internal AtmosphericPostProcessGraph( IGpuDevice device, RenderPackDescriptor descriptor, @@ -609,8 +621,8 @@ internal sealed class AtmosphericPostProcessGraph : targets.SunRaysSlot, AtmosphericPackPassUniforms.From(new Vector4( _settings.BloomStrength, - 1f, - 0.45f, + BloomThresholdLinear, + BloomKneeLinear, volumetric.HasTexture ? 1f : 0f)), frameBlock, settingsBlock, @@ -666,8 +678,8 @@ internal sealed class AtmosphericPostProcessGraph : _fuseLowPostProcess ? new Vector4( _settings.BloomStrength, - 1f, - 0.45f, + BloomThresholdLinear, + BloomKneeLinear, volumetric.HasTexture ? 1f : 0f) : Vector4.Zero, _fuseLowPostProcess diff --git a/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs b/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs index b52c3716..1df36e8e 100644 --- a/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs +++ b/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs @@ -251,6 +251,17 @@ internal static class BuiltInAtmosphericRenderPack 0.65, 0, 2, 0.05), Float("filmic-strength", "Filmic tonemap strength", RenderSettingSemantic.FilmicStrength, 1.0, 0, 1, 0.05), + // Campaign VM VM3: exposure 0.80 is unchanged by the linear-light post + // stack move. Before VM3, exposure multiplied gamma-encoded values + // directly; after VM3 it multiplies decoded linear light instead, but + // the accepted midtone survives the swap almost exactly: + // encode(acesFitted(0.80 * decode(0.46))) ~= 0.50, matching the old + // gamma-space pipeline's 0.51 for the same 0.46 mid-grey input (see + // AtmosphericColorPipelineTests for the pinned numbers). Two visible + // side effects the owner's visual gate judges: highlights now retain + // more (a gamma 0.9 input moves from ~0.74 to ~0.85 through the full + // exposure/ACES pipeline) and blacks deepen slightly (gamma 0.1 moves + // from ~0.09 to ~0.05). Float("exposure", "Exposure", RenderSettingSemantic.Exposure, 0.80, 0.25, 4, 0.05), Float("grade-saturation", "Colour saturation", RenderSettingSemantic.GradeSaturation, diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.frag index 84cf49c2..fc4f302f 100644 --- a/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.frag +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.frag @@ -7,6 +7,9 @@ layout(location = 0) out vec4 oColor; void main() { + // Campaign VM VM3: uTextureIndexA is the bloom buffer written by + // atmospheric_bloom_downsample.frag, already decoded to linear light — + // this separable Gaussian pass only filters it, so no decode/encode here. vec2 stepUv = uPackParams0.xy; vec3 value = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb * 0.227027; value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv + stepUv * 1.384615).rgb * 0.316216; diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.frag index efff81e7..d808bb14 100644 --- a/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.frag +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.frag @@ -7,10 +7,14 @@ layout(location = 0) out vec4 oColor; void main() { - vec3 scene = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb - + ACDREAM_SAMPLE_2D(uTextureIndexB, vUv).rgb; + // Campaign VM VM3: A (world colour) and B (sun rays) are retail's + // gamma-encoded display-space output; C (volumetric shafts) is the same. + // Decode each to linear light before summing so the threshold/knee below + // — and the bloom buffer this pass writes — operate in linear light. + vec3 scene = acdreamDecodeDisplay(ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb) + + acdreamDecodeDisplay(ACDREAM_SAMPLE_2D(uTextureIndexB, vUv).rgb); if (uPackParams0.w > 0.5) - scene += ACDREAM_SAMPLE_2D(uTextureIndexC, vUv).rgb; + scene += acdreamDecodeDisplay(ACDREAM_SAMPLE_2D(uTextureIndexC, vUv).rgb); float brightness = dot(scene, vec3(0.2126, 0.7152, 0.0722)); float threshold = uPackParams0.y; float knee = max(uPackParams0.z, 0.0001); diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_common.glsl b/src/AcDream.App/Rendering/Shaders/atmospheric_common.glsl index 659fcd68..77d70cc5 100644 --- a/src/AcDream.App/Rendering/Shaders/atmospheric_common.glsl +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_common.glsl @@ -7,7 +7,7 @@ // reserved for directional-shadow data in directional_shadow_common.glsl. layout(std140, ACDREAM_PACK_UBO_SET binding = 5) uniform AtmosphericFrame { vec4 uAtmosphereSunScreen; // 0: uv.xy, ray strength, elevation degrees - vec4 uAtmosphereSunColor; // 16: authored linear rgb, policy multiplier + vec4 uAtmosphereSunColor; // 16: authored display-space rgb (retail has no linear pipeline), policy multiplier vec4 uAtmosphereViewport; // 32: width, height, reciprocal width/height vec4 uAtmosphereWeather; // 48: kind, intensity, delta seconds, outdoor vec4 uAtmosphereSunDirection; // 64: surface-to-sun xyz, authored brightness @@ -31,4 +31,23 @@ layout(std140, ACDREAM_PACK_UBO_SET binding = 8) uniform PackSettings { vec4 uPackSettings[16]; // 64 declaration-order scalar setting slots }; +// Campaign VM VM3 (2026-08-22): the main-world intermediate holds retail's +// fixed-function output verbatim, which retail authored (and displayed) as +// gamma-encoded colour — the 2013 client has no linear lighting pipeline. +// Every post-process value read from that intermediate (world colour, sun +// rays, volumetric shafts, bloom) must be decoded to linear light before any +// linear-space math (thresholding, ACES, luma, contrast) and re-encoded +// before it reaches the UNORM swapchain. 2.2 is the retail-era CRT/early-LCD +// display-gamma assumption; it is deliberately NOT the sRGB piecewise curve, +// which would claim a precision retail's authoring pipeline never had. +vec3 acdreamDecodeDisplay(vec3 c) +{ + return pow(max(c, vec3(0.0)), vec3(2.2)); +} + +vec3 acdreamEncodeDisplay(vec3 c) +{ + return pow(max(c, vec3(0.0)), vec3(1.0 / 2.2)); +} + #endif diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag index 0e50f4f1..dd5bc898 100644 --- a/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag @@ -22,10 +22,14 @@ vec3 sampleBloom(vec2 uv) vec3 lowFusedScene(vec2 uv) { - vec3 scene = ACDREAM_SAMPLE_2D(uTextureIndexA, uv).rgb - + ACDREAM_SAMPLE_2D(uTextureIndexB, uv).rgb; + // Campaign VM VM3: A (world colour), B (sun rays) and C (volumetric + // shafts) are retail's gamma-encoded display-space output. Decode each + // to linear light before summing, so the fused bloom extraction below + // (which reads this same sum) also runs in linear. + vec3 scene = acdreamDecodeDisplay(ACDREAM_SAMPLE_2D(uTextureIndexA, uv).rgb) + + acdreamDecodeDisplay(ACDREAM_SAMPLE_2D(uTextureIndexB, uv).rgb); if (uPackParams2.w > 0.5) - scene += ACDREAM_SAMPLE_2D(uTextureIndexC, uv).rgb; + scene += acdreamDecodeDisplay(ACDREAM_SAMPLE_2D(uTextureIndexC, uv).rgb); return scene; } @@ -67,11 +71,15 @@ void main() hdr = scene + lowFusedBloom(scene); } else { - hdr = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb + // Campaign VM VM3: A (world colour) and C (sun rays) are retail's + // gamma-encoded display-space output; sampleBloom's B is already + // linear (decoded in atmospheric_bloom_downsample.frag), and D + // (volumetric shafts) is display-space like the world buffer. + hdr = acdreamDecodeDisplay(ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb) + sampleBloom(vUv) - + ACDREAM_SAMPLE_2D(uTextureIndexC, vUv).rgb; + + acdreamDecodeDisplay(ACDREAM_SAMPLE_2D(uTextureIndexC, vUv).rgb); if (uPackParams1.y > 0.5) - hdr += ACDREAM_SAMPLE_2D(uTextureIndexD, vUv).rgb; + hdr += acdreamDecodeDisplay(ACDREAM_SAMPLE_2D(uTextureIndexD, vUv).rgb); } vec3 exposed = max(hdr * uPackParams0.x, vec3(0.0)); vec3 linearClamped = clamp(exposed, 0.0, 1.0); @@ -79,10 +87,18 @@ void main() float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722)); color = mix(vec3(luminance), color, uPackParams0.y); - color = (color - 0.5) * uPackParams0.z + 0.5; + // 0.18 is linear mid-grey (the standard 18%-grey-card exposure + // convention; retail's old 0.5 pivot was the gamma-encoded value for + // this same grey, pow(0.18, 1/2.2) ~= 0.459, rounded up for a stronger + // gamma-space contrast feel). Contrast now pivots around the correct + // linear grey point. + const float LinearMidGrey = 0.18; + color = (color - LinearMidGrey) * uPackParams0.z + LinearMidGrey; vec2 centered = vUv * 2.0 - 1.0; float vignette = smoothstep(1.25, 0.25, dot(centered, centered)); color *= mix(1.0, vignette, clamp(uPackParams0.w, 0.0, 1.0)); - oColor = vec4(clamp(color, 0.0, 1.0), 1.0); + // Campaign VM VM3: clamp in linear light, then re-encode for the UNORM + // swapchain — the counterpart of the acdreamDecodeDisplay() calls above. + oColor = vec4(acdreamEncodeDisplay(clamp(color, 0.0, 1.0)), 1.0); } diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.frag index a714590f..753be573 100644 --- a/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.frag +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.frag @@ -53,6 +53,9 @@ void main() sum += mask * illumination; illumination *= decay; } + // Campaign VM VM3: uAtmosphereSunColor is authored display-space rgb; this + // pass writes a display-space colour, and the filmic/bloom consumers + // decode it consistently with the world buffer. vec3 rays = uAtmosphereSunColor.rgb * (sum * weight / float(SampleCount)); oColor = vec4(rays, 1.0); } diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.frag index 843fd0c2..18c2e86e 100644 --- a/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.frag +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.frag @@ -70,6 +70,9 @@ void main() float integrated = lit / float(steps); float extinction = 1.0 - exp(-uPackParams0.x * length(sceneWorld - nearWorld)); + // Campaign VM VM3: uAtmosphereSunColor is authored display-space rgb; this + // pass writes a display-space colour, and the filmic/bloom consumers + // decode it consistently with the world buffer. vec3 color = uAtmosphereSunColor.rgb * (integrated * extinction * uPackParams0.y); oColor = vec4(max(color, vec3(0.0)), 1.0); diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_downsample.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_downsample.frag.spv index ac62a9dc32264ccf0073cb170adeebc6188e88fe..588d1eedd4171c212f232ff1dd24d9b7901913a8 100644 GIT binary patch literal 2952 zcmZ9M>uy$65QcZ#uPxZhLD6y${MxD}5F z71f$sG#5>%%G>iQc_+#H$d00|^f!h_hx<;Pe7A4yE33_%RkV~Fm^-_8tf(Qy#M?)X z=~x7Mz>A;(E`vGb7}H*IwKAp~-POPoInAZrQq+;^L-^EPtzd{}U4!Lbb3o4Z>R<)e zgf2IjaqcU(HRJ3^ZYbldS8h1ttU+!h{>Mm<>ITyK@OUy5FjH_pHE$DK-c;8b4*$wP3 z>>hOEtZxh%_1Syq<5POAybsaSlT>Q0-Q>E5`qOC6v<|vB)3dbouf$fL1zfAJ^v?!z zfry(6_xpp+!H9bTTQ0Dmn8_K{{?5cTT`uC}`S8XqMqhx`u2aiGq)Tof+1_QBwLgQj zUhmR;o{N!k+E4Lx4t3y}+Rr8%fv=_tExV z$wi%hhc0dF+Xj69=5I$D>pAhBccQa4&%?6|CHC2i?)@-t6luSC-eK&AH^(={KJQ_V zcdg$X_7Ut$e}1zu?$7(!<`}2XIP04LF$eAAAo}sVlc4MFAN{fKX>8VD*psC>S)ObD z#nP_o{t?~Youl*8_Ma-Zj<@MK-Y1vb-!Q)8Ej)e4&x0P&3pS>DUr79DvX^4Znd3cJ z4!pAM*Q`hFwV?_+o3b|U}c*6cyL{v}@D2$1tGsXGI|pM9VQ>;d^-PQbZc;L?}qWGfV_F1B<>8l{~ftc6L%KfSbd#*bLO4{p0yI_ z_ZyuAvCjo`V?@;V8M-;v=eg!Y{O9QAcy8491-d!N4^g_>Ud>!T&yxwRQji literal 2720 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 literal 7016 zcmZ9QeUR5x6~=$dBFKxpgn)(M0x=`9txzfp%Pz}8K}qZqj!IEZlT(?3Falz!gE1tI z46DKxD$T@zl2%jX>y)JlQT?{l7W?!D)} zo%`Kw95QowZCFEXRPDUlV?%27GQKvX)&R18>imKF>8k!Q^{Cpgx_$Y|H7i?g*?fJ= zWtY#j;h0)uy}fP6)<)KbQY-5}d&33|ZAcfQ(vS4vIF2^@3`drO^qG!WqR#*u+H!n5 zE4%_|O1;0C0DE2iDDY=;%DB+AzuCw5VRNS(Hb4L2DC^+MlTUl_%AlFlH!z%8o zf-^S#t}VE8D{dXNbG(V#Sd3L{v5h5M;?F$OM!%-QuMsTg2DzTDz3ZFxR-b|Br@fzg z8e%b)W(InaJr{qVz%2z{RN(dkcNDn0z&!Fz|R!; z*#iHfz`rc;uL}GJ@CDVJ|5)IY1^!ckFJgnTE|(N|L4g+*xUIlj3w&#Vw^euoyANca z8teNao&@qdH6fGPC-+a?eN#WL()FE#u5Bvv`$J8g(VNhFUp&1i`YiN|c1{_zUyR;9 za`NKXo6%>l8`}}xGh~TAudZ!h{NCD#+I+BMvPAWAc0GkiTe0Xj6}*62y@^;CQd{KO zD&0I_ac+yK?b~~I0l(X+<aw|F+*$*^Dduvl&cTd_KKw0i>VXz@8u5eVN+$GoRg+t?Z{4?zc~@9Xtp3p|71h zfAE~#kG}up-u7mCvEK&}+vumwagDbRX(;pu!I_`q9Y7{O^HOW#Ka5W4O#9~s^jgL_ zhVGe+WPT?qKj-%{*fG5yuB&=~#iid@(A}R4vA;@fEOLqCHSk}KuX-W+>*(X!jy@m# z&jb44dYl^Y?`*D}Tkr1;bo;QKvFfjn@x4jynmMlhdA?2~+1E2**U0md@rE#=XHWIE zM{h(Q_xi!X{TYgW{I)N4Wc+i`ZKIzyYxRsb0nE>!ekF=;Qu@DJW9?gm^V?O|w|5x- zKCrPGpX;mcyDpb~I>ZFb6_N0V;hW$i;U9rF7e&H93f~O>e;GJ$N8ml{k!E(&{r)z5 z#=HU!l?Vrl@x2A(zPIt)eeex%FWj$iz6EDsfDS zbkEilbZtLkT<_(DU^(OeCo4A%YuUM?ypUmY+2b#gXFdGpk)>pA$Gl%+?^f_hu=h#cyCk;nm#JMF>-616 z-^b{~@eYC~All5G`@knE+r-Ltlr{C9y@J1X@5dR|a8hMQt?!5SR}p!~6dS{9)aurW z!Eud4pE1;9k;JecYz*4QSGL4(3Ql`scu!?d4BFp75SAe^y%RDk4PCj|06)cxL;@>cG_Td}h z-!bM9`OL|`XXKMdI`u)yIW0k#S1f1GX(_z(($6!P_^or#9;$pim-;)m^1L<_z7K=V zJDzFV+0Q=aU|wDY*6z1ya(Fj-a(ExSbJMp6UR(TD7k=eC{t0+(quC+btpPhnzgx5& zpx#l9e>J)>*tdJC|FvLw+j*|#J_$ZdEw`)UJ_XiS+i2cT{nmjUYYw8_GwQca`ne8H zA0_d94xIQL*EaG0Ji2WhH}QP|Y#VLq;|6g0a6Zm4ao&h-8-4Z=yXSTT*mc*I^}HFJ zb$(nvV@%y$&ncJdi-G}tk< zxdz5R27D0F=D8b7?H#k;j*oB7c(C#Kou^OU3v;;o1Zvw@r_DsL@uf}Pxk+#t`(par zi{6LW&op%VnN96n{pPy_+(lh}^UZ;i&wXwN%h|^J?Ei7R&+@s?Ens@3YIJ5U4|~NSgZ^^@qQS>nDxuO`v};3=b0<>-Ewqo`DR@KmdiKm z$G{ff8f_m%v1Kub1_eS94w#pSOu2LdUS*3vK~EP*F!$*u^KF&^|%@= zSFXo3=<gfZu{T(=wHwdL)*3CvHuA8mR2l7rU6=@ZHL8{qh>_YTQ?0dk8eItI!554f(($9Theg@76AbT78{gvH4^n1r~ZIik01v^*$veplR z`KkA*J?Ad(*h6soSi(JAanj)qR2)me{tmDI&GJn++v~60nEV^I53KE7Ms7v^2lRh} AF#rGn diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 45208862..0a569cc4 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -12,7 +12,7 @@ }, { "stage": "frag", - "sourceSha256": "4b22872f61b462cdbc82d4af38c7693212bc7446c7c68faa32dd5585b08c43ae", + "sourceSha256": "de77256a5edde4f50c0d50544cb7e15e7d6b618636f2c529750b20e7a779cf65", "compiled": true } ] @@ -28,7 +28,7 @@ }, { "stage": "frag", - "sourceSha256": "a01f09dfcc62acc5376f6e61be6aa2e8c5b0506550c0fa318f4434cbf3e6a17a", + "sourceSha256": "f8f21269fdf4d994844a747842d68bbda5afcddb5dccccdcec11563450edba52", "compiled": true } ] @@ -44,7 +44,7 @@ }, { "stage": "frag", - "sourceSha256": "240d2fe5e13e3850ceb79f178f1c248c71875fdc1973ff8cab1c274660ebbc4b", + "sourceSha256": "a08a297de0736e1798b7ea492c26de0fbdc509759a0423ae76260ac0c7d4a9ee", "compiled": true } ] @@ -60,7 +60,7 @@ }, { "stage": "frag", - "sourceSha256": "bfbc8c508b21dec84b21b2760877bfcb736c4f233e8557f6d1f8b83600683d5e", + "sourceSha256": "45d02b356ada9fb15052290188d0de85e666de94f7bec7dc34c4e405b31401ef", "compiled": true } ] @@ -76,7 +76,7 @@ }, { "stage": "frag", - "sourceSha256": "13875d9f6fd28f1049d1f94741db13086f72cc7170659eb73c369b4c99b00a4f", + "sourceSha256": "9765a397328fe9a002a825ae806fcd1651645458e2909dc9ff96a1759c4690d9", "compiled": true } ] @@ -92,7 +92,7 @@ }, { "stage": "frag", - "sourceSha256": "0195fa5fdfb3850e090bed6f144981d454edfed526b79efe8dd5300b610b2f0f", + "sourceSha256": "93aeef7ce7555c9c184ee8d7e419c207b2fbed4d877788f016faf19832816b0d", "compiled": true } ] diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericColorPipelineTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericColorPipelineTests.cs new file mode 100644 index 00000000..7157dc67 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericColorPipelineTests.cs @@ -0,0 +1,143 @@ +using System.Numerics; +using AcDream.App.Rendering.Packs; + +namespace AcDream.App.Tests.Rendering.Packs; + +/// +/// Campaign VM VM3: pins , the CPU +/// mirror of atmospheric_common.glsl's decode/encode helpers and +/// atmospheric_filmic.frag's tonemap chain, against the numbers the design +/// derived by hand — chiefly that the neutral preset is numerically the +/// pack-off image, and that the accepted exposure/knee constants survive the +/// gamma-to-linear move. +/// +public sealed class AtmosphericColorPipelineTests +{ + private const float EightBitHalfStep = 1f / 510f; + + [Fact] + public void NeutralPresetReproducesThePackOffPixelWithinHalfAnEightBitStep() + { + for (int step = 0; step <= 255; step++) + { + float c = step / 255f; + var pixel = new Vector3(c, c, c); + Vector3 result = AtmosphericColorPipeline.Encode( + AtmosphericColorPipeline.Filmic( + AtmosphericColorPipeline.Decode(pixel), + exposure: 1f, + filmicStrength: 0f, + saturation: 1f, + contrast: 1f, + vignetteFactor: 1f)); + + Assert.True( + MathF.Abs(result.X - c) < EightBitHalfStep, + $"channel diverged at c={c}: got {result.X}"); + Assert.True( + MathF.Abs(result.Y - c) < EightBitHalfStep, + $"channel diverged at c={c}: got {result.Y}"); + Assert.True( + MathF.Abs(result.Z - c) < EightBitHalfStep, + $"channel diverged at c={c}: got {result.Z}"); + } + } + + [Fact] + public void DecodeAndEncodeRoundTripWithinFloatPrecision() + { + for (int step = 0; step <= 255; step++) + { + float c = step / 255f; + var pixel = new Vector3(c, c, c); + + Vector3 decodedThenEncoded = AtmosphericColorPipeline.Encode( + AtmosphericColorPipeline.Decode(pixel)); + Vector3 encodedThenDecoded = AtmosphericColorPipeline.Decode( + AtmosphericColorPipeline.Encode(pixel)); + + Assert.True(MathF.Abs(decodedThenEncoded.X - c) < 1e-6f); + Assert.True(MathF.Abs(encodedThenDecoded.X - c) < 1e-6f); + } + } + + [Fact] + public void FilmicOutputIsMonotonicNonDecreasingInExposure() + { + Vector3 hdr = AtmosphericColorPipeline.Decode(new Vector3(0.5f, 0.5f, 0.5f)); + float previous = -1f; + for (float exposure = 0.1f; exposure <= 3.0f; exposure += 0.1f) + { + Vector3 result = AtmosphericColorPipeline.Filmic( + hdr, + exposure, + filmicStrength: 1f, + saturation: 1f, + contrast: 1f, + vignetteFactor: 1f); + + // Half an 8-bit step of float slack absorbs the ACES curve's + // near-flat top without hiding an actual regression. + Assert.True( + result.X >= previous - EightBitHalfStep, + $"exposure={exposure}: {result.X} < previous {previous}"); + previous = result.X; + } + } + + [Fact] + public void AcceptedExposurePointReproducesTheOwnerGatedMidtone() + { + // encode(acesFitted(0.80 * decode(0.46))) ~= 0.50 — the same 0.46 + // grey-card mid-grey input the old gamma-space pipeline mapped to + // 0.51 directly (0.46 * 0.80 fed straight into acesFitted with no + // decode/encode). See the "exposure" setting comment in + // BuiltInAtmosphericRenderPack.cs for the full derivation. + Vector3 midtone = AtmosphericColorPipeline.Encode( + AtmosphericColorPipeline.Filmic( + AtmosphericColorPipeline.Decode(new Vector3(0.46f, 0.46f, 0.46f)), + exposure: 0.80f, + filmicStrength: 1f, + saturation: 1f, + contrast: 1f, + vignetteFactor: 1f)); + Assert.InRange(midtone.X, 0.50f - 0.02f, 0.50f + 0.02f); + + // Highlights retain more than the old gamma-space pipeline. + Vector3 highlight = AtmosphericColorPipeline.Encode( + AtmosphericColorPipeline.Filmic( + AtmosphericColorPipeline.Decode(new Vector3(0.9f, 0.9f, 0.9f)), + exposure: 0.80f, + filmicStrength: 1f, + saturation: 1f, + contrast: 1f, + vignetteFactor: 1f)); + Assert.InRange(highlight.X, 0.85f - 0.02f, 0.85f + 0.02f); + + // Blacks deepen slightly relative to the old gamma-space pipeline. + Vector3 shadow = AtmosphericColorPipeline.Encode( + AtmosphericColorPipeline.Filmic( + AtmosphericColorPipeline.Decode(new Vector3(0.1f, 0.1f, 0.1f)), + exposure: 0.80f, + filmicStrength: 1f, + saturation: 1f, + contrast: 1f, + vignetteFactor: 1f)); + Assert.InRange(shadow.X, 0.05f - 0.02f, 0.05f + 0.02f); + } + + [Fact] + public void BloomKneeDerivationMatchesTheGraphsLinearConstant() + { + // Pre-VM3 gamma-space soft range was [0.55, 1.0] (threshold 1.0, + // knee 0.45). Decoding both ends with the same 2.2 assumption gives + // the linear range this pack now uses. + float decodedLowerBound = MathF.Pow(0.55f, AtmosphericColorPipeline.DisplayGamma); + Assert.InRange(decodedLowerBound, 0.27f - 0.01f, 0.27f + 0.01f); + + float derivedKnee = AtmosphericPostProcessGraph.BloomThresholdLinear - decodedLowerBound; + Assert.InRange(derivedKnee, 0.73f - 0.01f, 0.73f + 0.01f); + Assert.Equal(AtmosphericPostProcessGraph.BloomKneeLinear, derivedKnee, 2); + Assert.Equal(1f, AtmosphericPostProcessGraph.BloomThresholdLinear); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs index da8617d7..5a05bcd2 100644 --- a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs @@ -102,7 +102,11 @@ public sealed class AtmosphericPostProcessGraphTests Assert.Equal(Vector4.Zero, rays.Params1); AtmosphericPackPassUniforms bloom = ReadPass(device, passBlocks[2]); Assert.Equal( - new Vector4(graph.Settings.BloomStrength, 1f, 0.45f, 0f), + new Vector4( + graph.Settings.BloomStrength, + AtmosphericPostProcessGraph.BloomThresholdLinear, + AtmosphericPostProcessGraph.BloomKneeLinear, + 0f), bloom.Params0); AtmosphericPackPassUniforms horizontal = ReadPass(device, passBlocks[3]); Assert.Equal(new Vector4(1f / 320f, 0f, 0f, 0f), horizontal.Params0); @@ -1163,6 +1167,50 @@ public sealed class AtmosphericPostProcessGraphTests 5); } + [Fact] + public void FilmicAndBloomDownsampleShadersKeepTheirColourSpaceConversions() + { + // Campaign VM VM3: a future edit to either shader could silently drop + // the decode/encode calls that make the post stack run in linear + // light. Pin their presence so that regresses loudly instead of + // quietly reintroducing F4 (gamma-space bloom/ACES/grade). + string shaderRoot = Path.Combine( + RepositoryRoot(), + "src", + "AcDream.App", + "Rendering", + "Shaders"); + string filmic = File.ReadAllText(Path.Combine(shaderRoot, "atmospheric_filmic.frag")); + string downsample = File.ReadAllText( + Path.Combine(shaderRoot, "atmospheric_bloom_downsample.frag")); + + int encodeInMainOutput = CountOccurrences( + filmic[filmic.IndexOf("void main()", StringComparison.Ordinal)..], + "acdreamEncodeDisplay("); + Assert.Equal(1, encodeInMainOutput); + Assert.Contains( + "oColor = vec4(acdreamEncodeDisplay(clamp(color, 0.0, 1.0)), 1.0);", + filmic, + StringComparison.Ordinal); + + int decodesInDownsample = CountOccurrences(downsample, "acdreamDecodeDisplay("); + Assert.True( + decodesInDownsample >= 3, + $"expected at least 3 acdreamDecodeDisplay( calls in atmospheric_bloom_downsample.frag, found {decodesInDownsample}"); + } + + private static int CountOccurrences(string haystack, string needle) + { + int count = 0; + int index = 0; + while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + return count; + } + private static AtmosphericPostProcessGraph Graph( RecordingGpuDevice device, string presetId,