From 51178f7c777cab30996dc6c2582958ecaab2ab9d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 22 Aug 2026 23:40:48 +0200 Subject: [PATCH] fix(render): VM3 review fixes - pin the filmic decodes, re-derive vignette for linear, name the authored sun colour Fix round from Opus review of 87677f9c (APPROVE WITH FIXES): 1. (A1/A3) Hardened the atmospheric_filmic.frag / atmospheric_bloom_ downsample.frag shader-source pinning test: asserts the exact decode call count (6 - three in lowFusedScene, three in main's non-fused branch), that the 2.2 display-gamma exponent and 1/2.2 inverse in atmospheric_common.glsl are formatted FROM AtmosphericColorPipeline .DisplayGamma (so shader literal and CPU-tested value cannot drift), that the 0.18 contrast pivot in atmospheric_filmic.frag is formatted from AtmosphericColorPipeline.LinearMidGrey, and pins AtmosphericPostProcessGraph.BloomKneeLinear/BloomThresholdLinear as exact literals (0.73f / 1f). Also removed a stray trailing "()" from an existing comment in atmospheric_filmic.frag that was inflating the decode-call count to 7. 2. (B1) Re-derived the "vignette-strength" default for the linear-light post stack. The vignette multiply now happens on linear colour before the final encode, so a corner factor of (1 - strength) displays as (1 - strength)^(1/2.2), not (1 - strength) directly. The accepted look was strength 0.12 under the OLD gamma-space pipeline: a 12% on-screen corner darkening. Under strength 0.12 in the new linear pipeline that same 0.88 corner multiplier would only display as 0.88^(1/2.2) ~= 0.9435 (5.6% darkening - visibly weaker). Solving (1 - strength)^(1/2.2) = 0.88 gives strength = 1 - 0.88^2.2 ~= 0.245, which reproduces the accepted 12% corner darkening. Since RenderPackSettingValueCodec requires every declared default to be step-aligned from the minimum and 0.245 is not a multiple of the old 0.01 step, the step also moves to 0.005 (a finer slider, not coarser) so the exact derived default is a valid grid point - verified by running the ExternalTierTwoPackCanRenameEveryOwnedId AndShaderAsset validation test, which failed with "invalid default value" before this correction. Also updated the matching fallback in AtmosphericPostProcessGraph.FromDescriptor (0.12f -> 0.245f) for consistency, and added AtmosphericColorPipelineTests.VignetteDefaultReproducesTheAccepted TwelvePercentCornerDarkening pinning encode(1-0.245) ~= 0.88. 3. (A4) Renamed VolumetricShaftFrameParameters.LinearSunColor -> AuthoredSunColor in VolumetricShaftQuality.cs (internal, 2 references, both in that file - safe). Left LightSource.ColorLinear unrenamed: grep shows 13 files depend on it (GlobalLightPacker, SceneLightingUbo, LightBake, LightManager, EnvCellRenderer, RenderingDiagnostics, and several Core tests) across the shared retail default-path lighting UBO pipeline - renaming it is out of VM3's pack-only scope and would touch the mandatory-unchanged default path. Added a pointer comment on the field in LightSource.cs (and a one-line note at its WorldRenderFrameBuilder.cs call site) documenting the same display-space-not-linear fact and explaining why the rename is deferred to its own default-path colour-space pass. 4. (B4) Added a citation beside acesFitted in both atmospheric_filmic .frag and its C# mirror (AtmosphericColorPipeline.AcesFitted): Krzysztof Narkowicz, "ACES Filmic Tone Mapping Curve" (2016). The fit takes linear scene light in and returns linear display light in [0,1] - it does not itself gamma-encode. Evidence: acesFitted(0.80 * decode(0.46)) = 0.2064 un-encoded versus the accepted 0.51 on screen. 5. (B2) Rewrote the VM3 section of docs/plans/2026-08-22-visualmaster- campaign.md with the shipped truth in place of the pre-implementation guess: exposure stays 0.80 (at exposure 1.0 the linear pipeline maps gamma-0.5 to 0.6017, essentially the same 0.6163 the owner called too bright), bloom threshold stays 1.0 (a fixed point of both exponents), knee moves 0.45 -> 0.73, vignette-strength moves 0.12 -> 0.245. Added the old-vs-new curve table at exposure 0.80 across ten gamma inputs. Replaced the acceptance criteria's "new automated test on the recording RHI" with the CPU mirror + shader-source pins actually used, and recorded that the real-frame masked capture WAS run (retail vs High-with-every-effect-neutral, artifacts/vm3): independently re-verified by re-running the pixel diff against the checked-in screenshots - 110,561 px at |delta|=1 and exactly 95 pixels at |delta|>=5, confined to foliage-canopy silhouette edges against sky with nothing on any ground/building/water surface. Noted the Stage-1 luminance table re-capture is still owed at the owner gate. 6. (B3) Corrected docs/plans/2026-08-21-atmospheric-rendering.md's VM3 summary sentence: the bloom intermediate is already linear after extraction (no separate "bloom read" decode), and the neutral-preset claim is now phrased as a measured numerical identity (<=1 LSB on a real frame) rather than an unqualified "is" statement. 7. (A5) Corrected toolchain attribution: tools/compile-shaders.ps1 used the managed Silk.NET.Shaderc path (shaderc_shared.dll) to compile in both this round and the original VM3 commit - a Vulkan SDK glslc was detected and its path recorded, but the managed compiler is what actually ran. Regenerating this round only changed the atmospheric_ filmic frag stage's manifest hash (comment-only edits); the compiled .spv bytes are unchanged, and every retail-oracle shader (mesh_modern, terrain_modern, mesh_detail, etc.) remains untouched. 8. Replaced an invented motive in the atmospheric_filmic.frag contrast- pivot comment ("rounded up for a stronger gamma-space contrast feel") with the actual reason: the previous 0.5 was simply the [0,1] midpoint of the standard contrast formula, not a deliberately chosen value; in linear the perceptual mid-grey is 0.18. Verify: Release build 0 warnings / 0 errors. App hermetic-filter tests: 5970 passed / 0 failed / 0 skipped. VulkanShaderManifestTests: 7/7 pass (retail-oracle SPIR-V byte-identical; only the atmospheric_filmic frag manifest hash changed, no .spv bytes changed). Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-08-21-atmospheric-rendering.md | 9 ++-- .../plans/2026-08-22-visualmaster-campaign.md | 54 ++++++++++++++++--- .../Packs/AtmosphericColorPipeline.cs | 10 +++- .../Packs/AtmosphericPostProcessGraph.cs | 2 +- .../Packs/BuiltInAtmosphericRenderPack.cs | 19 ++++++- .../Rendering/Shaders/atmospheric_filmic.frag | 16 ++++-- .../Shaders/spv/shaders.manifest.json | 2 +- .../Rendering/VolumetricShaftQuality.cs | 4 +- .../Rendering/WorldRenderFrameBuilder.cs | 4 ++ src/AcDream.Core/Lighting/LightSource.cs | 14 ++++- .../Packs/AtmosphericColorPipelineTests.cs | 17 ++++++ .../Packs/AtmosphericPostProcessGraphTests.cs | 21 ++++++++ 12 files changed, 151 insertions(+), 21 deletions(-) diff --git a/docs/plans/2026-08-21-atmospheric-rendering.md b/docs/plans/2026-08-21-atmospheric-rendering.md index 05efa064..d540c0d9 100644 --- a/docs/plans/2026-08-21-atmospheric-rendering.md +++ b/docs/plans/2026-08-21-atmospheric-rendering.md @@ -575,9 +575,12 @@ 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. +world/ray/volumetric read is decoded with the 2.2 display assumption; the +bloom intermediate is already linear (decoded once at extraction, so +downstream blur/composite reads need no further decode). Tonemap/grade/ +vignette run in linear and the result is re-encoded; the post stack's +neutral settings are a numerical identity (measured on a real frame at +Campaign VM VM3: ≤1 LSB). ### Slice 2 — Tier-1 screen-space sun rays diff --git a/docs/plans/2026-08-22-visualmaster-campaign.md b/docs/plans/2026-08-22-visualmaster-campaign.md index 70b5571e..349c72c0 100644 --- a/docs/plans/2026-08-22-visualmaster-campaign.md +++ b/docs/plans/2026-08-22-visualmaster-campaign.md @@ -210,13 +210,55 @@ compensation. vignette 0) — the existing "every effect can be set to neutral" acceptance now actually holds numerically, and is asserted by a test. - Opinionated defaults (what the owner accepted at 0.80 exposure) are - re-derived in linear and presented for the visual gate; expect exposure to - return to ~1.0 and the bloom threshold to move up. + re-derived in linear and presented for the visual gate. **Shipped truth + (not the pre-implementation guess above):** exposure stays at **0.80** — + at exposure 1.0 the linear pipeline maps gamma-0.5 to 0.6017, essentially + the same 0.6163 the owner called too bright under the old gamma-space + pipeline, so 1.0 reproduces the rejected look, not a corrected one. The + bloom **threshold stays 1.0** (a fixed point of both `pow(x,2.2)` and + `pow(x,1/2.2)` — 1.0 decodes and encodes to 1.0), while the **knee moves + 0.45 → 0.73** (`AtmosphericPostProcessGraph.BloomKneeLinear`) and + **vignette-strength moves 0.12 → 0.245** (re-derived so the same accepted + 12% corner darkening survives the encode step). See + `AtmosphericColorPipelineTests` for the pinned numbers behind each of + these. -**Acceptance:** neutral preset ≡ pack-off within tolerance (new automated -test on the recording RHI plus VM0's masked tool on a real capture); Stage-1 -luminance table re-captured; owner visual gate: "same look as accepted, no -clipping, highlights roll off". Budget unchanged (two `pow` per pixel). + Old-vs-new curve at the shipped exposure 0.80 (gamma input → + old-pipeline display value / new-pipeline display value; filmic + strength 1, saturation/contrast 1, no vignette — i.e. `acesFitted` + applied directly to the gamma value versus `encode(acesFitted(0.80 * + decode(g)))`): + + | gamma in | old display | new display | + |---:|---:|---:| + | 0.05 | 0.031 | 0.023 | + | 0.10 | 0.091 | 0.052 | + | 0.15 | 0.162 | 0.091 | + | 0.20 | 0.233 | 0.140 | + | 0.30 | 0.360 | 0.265 | + | 0.46 | 0.511 | 0.488 | + | 0.50 | 0.541 | 0.539 | + | 0.70 | 0.652 | 0.735 | + | 0.90 | 0.725 | 0.845 | + | 1.00 | 0.752 | 0.879 | + + Shadows deepen slightly (0.05–0.30 gamma read darker), the crossover sits + near gamma 0.5 (old and new agree almost exactly there — expected, since + that is close to the 0.46 midtone the exposure was tuned against), and + highlights above roughly gamma 0.5 now read brighter instead of + compressing toward ACES's ~0.75 ceiling. + +**Acceptance:** neutral preset ≡ pack-off within tolerance — proved by the +CPU mirror `AtmosphericColorPipeline` (`AtmosphericColorPipelineTests`, +half-an-8-bit-step identity over a 0..255 grey sweep) plus shader-source +pins (`AtmosphericPostProcessGraphTests`) guarding the decode/encode call +sites against silent removal. The real-frame masked capture **was run** +(retail vs High-with-every-effect-neutral, `artifacts/vm3`): 110,561 px at +`|Δ|=1` (float/pow round-trip noise, sub-visible) and 95 isolated +foliage-edge pixels at `|Δ|≥5`, nothing on any ground/building/water +surface. Stage-1's luminance table re-capture is still **owed** at the +owner gate; owner visual gate: "same look as accepted, no clipping, +highlights roll off". Budget unchanged (two `pow` per pixel). ## VM4 — Truthful documents (F5) diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericColorPipeline.cs b/src/AcDream.App/Rendering/Packs/AtmosphericColorPipeline.cs index f287604d..aecb036b 100644 --- a/src/AcDream.App/Rendering/Packs/AtmosphericColorPipeline.cs +++ b/src/AcDream.App/Rendering/Packs/AtmosphericColorPipeline.cs @@ -45,7 +45,15 @@ internal static class AtmosphericColorPipeline internal static Vector3 Encode(Vector3 c) => Pow(Vector3.Max(c, Vector3.Zero), 1f / DisplayGamma); - /// Mirrors acesFitted (Narkowicz fit) in atmospheric_filmic.frag. + /// + /// Mirrors acesFitted (Narkowicz fit) in atmospheric_filmic.frag. + /// Krzysztof Narkowicz, "ACES Filmic Tone Mapping Curve" (2016 blog + /// post). The fit takes LINEAR scene light in and returns LINEAR + /// display light in [0,1] — it does not itself gamma-encode; callers + /// must decode their input and encode the final output. Evidence it is + /// not already encoded: AcesFitted(0.80 * Decode(0.46)) is + /// 0.2064 un-encoded, versus the accepted 0.51 on screen. + /// internal static Vector3 AcesFitted(Vector3 value) { const float a = 2.51f; diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs index 94d1a2e1..3f1047e3 100644 --- a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs +++ b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs @@ -40,7 +40,7 @@ internal readonly record struct AtmosphericPostProcessSettings( Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.Exposure, 1f), Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.GradeSaturation, 1f), Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.GradeContrast, 1f), - Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.VignetteStrength, 0.12f), + Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.VignetteStrength, 0.245f), Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.SunRayStrength, 0.55f)); } diff --git a/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs b/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs index 1df36e8e..f1bfb36f 100644 --- a/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs +++ b/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs @@ -268,8 +268,25 @@ internal static class BuiltInAtmosphericRenderPack 1.0, 0, 2, 0.05), Float("grade-contrast", "Colour contrast", RenderSettingSemantic.GradeContrast, 1.0, 0.5, 2, 0.05), + // Campaign VM VM3: re-derived for the linear-light post stack. The + // vignette multiply now happens on linear colour before the final + // encode, so a corner factor of (1 - strength) displays as + // (1 - strength)^(1/2.2), not (1 - strength). The accepted look was + // strength 0.12 under the OLD gamma-space pipeline: a corner + // multiplied directly by 0.88, i.e. a 12% on-screen darkening. Under + // strength 0.12 in linear that same 0.88 would display as + // 0.88^(1/2.2) ~= 0.9435 — only a 5.6% darkening, visibly weaker. + // Solving (1 - strength)^(1/2.2) = 0.88 for strength gives + // strength = 1 - 0.88^2.2 ~= 0.245, which reproduces the accepted + // 12% corner darkening on screen (see + // AtmosphericColorPipelineTests for the pinned encode(1-0.245)~=0.88 + // check). Step moves 0.01 -> 0.005 so the exact derived default + // lands on the settings grid (RenderPackSettingValueCodec requires + // every declared default to be step-aligned from the minimum; + // 0.245 / 0.01 is not integral, 0.245 / 0.005 = 49 is) — a finer + // slider, not a coarser one. Float("vignette-strength", "Vignette strength", RenderSettingSemantic.VignetteStrength, - 0.12, 0, 1, 0.01), + 0.245, 0, 1, 0.005), Float("sun-ray-strength", "Sun-ray strength", RenderSettingSemantic.SunRayStrength, 0.55, 0, 2, 0.05), Float("sun-shadow-strength", "Directional-shadow strength", diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag index dd5bc898..be1dad52 100644 --- a/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag +++ b/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag @@ -5,6 +5,13 @@ layout(location = 0) out vec4 oColor; #include "atmospheric_common.glsl" +// Krzysztof Narkowicz, "ACES Filmic Tone Mapping Curve" (2016 blog post). +// The fit takes LINEAR scene light in and returns LINEAR display light in +// [0,1] — it does not itself gamma-encode, so callers must decode their +// input (acdreamDecodeDisplay) and encode the final output +// (acdreamEncodeDisplay). Evidence it is not already encoded: +// acesFitted(0.80 * decode(0.46)) = 0.2064 un-encoded, versus the accepted +// 0.51 on screen (see AtmosphericColorPipelineTests for the pinned numbers). vec3 acesFitted(vec3 value) { const float a = 2.51; @@ -88,10 +95,9 @@ void main() float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722)); color = mix(vec3(luminance), color, uPackParams0.y); // 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. + // convention); the previous 0.5 was the [0,1] midpoint of the standard + // contrast formula — in linear the perceptual mid-grey is 0.18, not 0.5. + // Contrast now pivots around the correct linear grey point. const float LinearMidGrey = 0.18; color = (color - LinearMidGrey) * uPackParams0.z + LinearMidGrey; @@ -99,6 +105,6 @@ void main() float vignette = smoothstep(1.25, 0.25, dot(centered, centered)); color *= mix(1.0, vignette, clamp(uPackParams0.w, 0.0, 1.0)); // Campaign VM VM3: clamp in linear light, then re-encode for the UNORM - // swapchain — the counterpart of the acdreamDecodeDisplay() calls above. + // 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/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 0a569cc4..aa31c7be 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -44,7 +44,7 @@ }, { "stage": "frag", - "sourceSha256": "a08a297de0736e1798b7ea492c26de0fbdc509759a0423ae76260ac0c7d4a9ee", + "sourceSha256": "dfc11d59f71d65efe650bcfa3434777cbcfe3358f5be38bf392f51b2bd2561b3", "compiled": true } ] diff --git a/src/AcDream.App/Rendering/VolumetricShaftQuality.cs b/src/AcDream.App/Rendering/VolumetricShaftQuality.cs index 717d59bd..97562c7f 100644 --- a/src/AcDream.App/Rendering/VolumetricShaftQuality.cs +++ b/src/AcDream.App/Rendering/VolumetricShaftQuality.cs @@ -45,7 +45,7 @@ internal readonly record struct VolumetricShaftFrameParameters( int RayMarchSteps, float Density, float Strength, - Vector3 LinearSunColor) + Vector3 AuthoredSunColor) { internal static VolumetricShaftFrameParameters Disabled => new(false, 0f, 0, 0f, 0f, Vector3.Zero); @@ -105,7 +105,7 @@ internal static class VolumetricShaftPolicy quality.RayMarchSteps, Density: 0.035f * strength, Strength: strength, - LinearSunColor: color); + AuthoredSunColor: color); } private static float SmoothStep(float minimum, float maximum, float value) diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index 9ab24c9e..28c0c246 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -561,6 +561,10 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation { Kind = LightKind.Directional, WorldForward = sunToWorld, + // keyframe.SunColor is retail's authored display-space colour; + // LightSource.ColorLinear is misleadingly named — see the note + // on that field (Campaign VM VM3 review item A4) for why it is + // left unrenamed here. ColorLinear = keyframe.SunColor, Intensity = 1f, Range = 1f, diff --git a/src/AcDream.Core/Lighting/LightSource.cs b/src/AcDream.Core/Lighting/LightSource.cs index ae3e8871..a33afb98 100644 --- a/src/AcDream.Core/Lighting/LightSource.cs +++ b/src/AcDream.Core/Lighting/LightSource.cs @@ -41,7 +41,19 @@ public sealed class LightSource public LightKind Kind; public Vector3 WorldPosition; public Vector3 WorldForward; // for Spot/Directional - public Vector3 ColorLinear = Vector3.One; // R,G,B in [0,1], pre-brightness + // R,G,B in [0,1], pre-brightness. NOTE (Campaign VM VM3, 2026-08-22 + // review item A4): despite the name, this is retail's authored + // DISPLAY-space colour, not linear light — the 2013 client has no + // linear lighting pipeline (see atmospheric_common.glsl's + // acdreamDecodeDisplay/acdreamEncodeDisplay and the "Colour space" + // section of docs/render-packs/semantic-bindings-v1.md for the same + // fact on the pack side). Left unrenamed here: this field feeds the + // retail default-path lighting UBO (GlobalLightPacker, SceneLightingUbo, + // LightBake, LightManager, EnvCellRenderer) across ~13 files, and Campaign + // VM VM3 is scoped to pack-on shaders/code only — the retail default + // path must not change. A future default-path colour-space pass should + // rename this to ColorDisplay (or equivalent) in its own reviewed change. + public Vector3 ColorLinear = Vector3.One; public float Intensity = 1f; public float Range = 10f; // metres, hard cutoff public float ConeAngle = 0f; // radians, Spot only diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericColorPipelineTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericColorPipelineTests.cs index 7157dc67..64d6ecf8 100644 --- a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericColorPipelineTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericColorPipelineTests.cs @@ -140,4 +140,21 @@ public sealed class AtmosphericColorPipelineTests Assert.Equal(AtmosphericPostProcessGraph.BloomKneeLinear, derivedKnee, 2); Assert.Equal(1f, AtmosphericPostProcessGraph.BloomThresholdLinear); } + + [Fact] + public void VignetteDefaultReproducesTheAcceptedTwelvePercentCornerDarkening() + { + // BuiltInAtmosphericRenderPack's "vignette-strength" default is + // 0.245 = 1 - 0.88^2.2, chosen so the corner's linear multiplier + // (1 - strength) encodes back to the accepted 0.88 (a 12% on-screen + // darkening) instead of the weaker 0.88^(1/2.2) ~= 0.9435 a + // strength of 0.12 would now produce. See the "vignette-strength" + // comment in BuiltInAtmosphericRenderPack.cs for the full + // derivation. + const float VignetteStrengthDefault = 0.245f; + float cornerMultiplier = 1f - VignetteStrengthDefault; + Vector3 displayed = AtmosphericColorPipeline.Encode( + new Vector3(cornerMultiplier, cornerMultiplier, cornerMultiplier)); + Assert.InRange(displayed.X, 0.88f - 0.005f, 0.88f + 0.005f); + } } diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs index 5a05bcd2..3a0f78ca 100644 --- a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Numerics; using System.Runtime.InteropServices; using AcDream.App.Plugins; @@ -1180,6 +1181,7 @@ public sealed class AtmosphericPostProcessGraphTests "AcDream.App", "Rendering", "Shaders"); + string common = File.ReadAllText(Path.Combine(shaderRoot, "atmospheric_common.glsl")); string filmic = File.ReadAllText(Path.Combine(shaderRoot, "atmospheric_filmic.frag")); string downsample = File.ReadAllText( Path.Combine(shaderRoot, "atmospheric_bloom_downsample.frag")); @@ -1193,10 +1195,29 @@ public sealed class AtmosphericPostProcessGraphTests filmic, StringComparison.Ordinal); + // Three in lowFusedScene (A/B/C) plus three in main's non-fused + // branch (A/C/D — sampleBloom's B is already linear). + Assert.Equal(6, CountOccurrences(filmic, "acdreamDecodeDisplay(")); + int decodesInDownsample = CountOccurrences(downsample, "acdreamDecodeDisplay("); Assert.True( decodesInDownsample >= 3, $"expected at least 3 acdreamDecodeDisplay( calls in atmospheric_bloom_downsample.frag, found {decodesInDownsample}"); + + // The contrast pivot and the decode/encode exponent are formatted + // from the C# mirror's own constants so the shader literal and the + // CPU-tested value cannot silently drift apart. + string gamma = AtmosphericColorPipeline.DisplayGamma.ToString(CultureInfo.InvariantCulture); + Assert.Contains($"vec3({gamma})", common, StringComparison.Ordinal); + Assert.Contains($"vec3(1.0 / {gamma})", common, StringComparison.Ordinal); + string pivot = AtmosphericColorPipeline.LinearMidGrey.ToString(CultureInfo.InvariantCulture); + Assert.Contains($"const float LinearMidGrey = {pivot}", filmic, StringComparison.Ordinal); + + // The linear bloom threshold/knee the graph writes into Params0/2 are + // pinned by their exact literal values here too (AtmosphericColorPipelineTests + // separately proves they derive correctly from the pre-VM3 gamma pair). + Assert.Equal(0.73f, AtmosphericPostProcessGraph.BloomKneeLinear); + Assert.Equal(1f, AtmosphericPostProcessGraph.BloomThresholdLinear); } private static int CountOccurrences(string haystack, string needle)