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 ac62a9dc..588d1eed 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_downsample.frag.spv and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_downsample.frag.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.frag.spv index 8950e78a..338dbdd3 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.frag.spv and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.frag.spv differ 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,