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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-22 23:40:48 +02:00
parent 87677f9c4f
commit 51178f7c77
12 changed files with 151 additions and 21 deletions

View file

@ -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. slice meets its preset GPU/VRAM budget.
Campaign VM VM3 (2026-08-22) moved the post stack to linear light: every 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, world/ray/volumetric read is decoded with the 2.2 display assumption; the
tonemap/grade/vignette run in linear, and the result is re-encoded; the bloom intermediate is already linear (decoded once at extraction, so
neutral preset is numerically the pack-off image. 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 ### Slice 2 — Tier-1 screen-space sun rays

View file

@ -210,13 +210,55 @@ compensation.
vignette 0) — the existing "every effect can be set to neutral" acceptance vignette 0) — the existing "every effect can be set to neutral" acceptance
now actually holds numerically, and is asserted by a test. now actually holds numerically, and is asserted by a test.
- Opinionated defaults (what the owner accepted at 0.80 exposure) are - Opinionated defaults (what the owner accepted at 0.80 exposure) are
re-derived in linear and presented for the visual gate; expect exposure to re-derived in linear and presented for the visual gate. **Shipped truth
return to ~1.0 and the bloom threshold to move up. (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 Old-vs-new curve at the shipped exposure 0.80 (gamma input →
test on the recording RHI plus VM0's masked tool on a real capture); Stage-1 old-pipeline display value / new-pipeline display value; filmic
luminance table re-captured; owner visual gate: "same look as accepted, no strength 1, saturation/contrast 1, no vignette — i.e. `acesFitted`
clipping, highlights roll off". Budget unchanged (two `pow` per pixel). 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.050.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) ## VM4 — Truthful documents (F5)

View file

@ -45,7 +45,15 @@ internal static class AtmosphericColorPipeline
internal static Vector3 Encode(Vector3 c) => internal static Vector3 Encode(Vector3 c) =>
Pow(Vector3.Max(c, Vector3.Zero), 1f / DisplayGamma); Pow(Vector3.Max(c, Vector3.Zero), 1f / DisplayGamma);
/// <summary>Mirrors <c>acesFitted</c> (Narkowicz fit) in atmospheric_filmic.frag.</summary> /// <summary>
/// Mirrors <c>acesFitted</c> (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: <c>AcesFitted(0.80 * Decode(0.46))</c> is
/// 0.2064 un-encoded, versus the accepted 0.51 on screen.
/// </summary>
internal static Vector3 AcesFitted(Vector3 value) internal static Vector3 AcesFitted(Vector3 value)
{ {
const float a = 2.51f; const float a = 2.51f;

View file

@ -40,7 +40,7 @@ internal readonly record struct AtmosphericPostProcessSettings(
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.Exposure, 1f), Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.Exposure, 1f),
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.GradeSaturation, 1f), Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.GradeSaturation, 1f),
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.GradeContrast, 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)); Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.SunRayStrength, 0.55f));
} }

View file

@ -268,8 +268,25 @@ internal static class BuiltInAtmosphericRenderPack
1.0, 0, 2, 0.05), 1.0, 0, 2, 0.05),
Float("grade-contrast", "Colour contrast", RenderSettingSemantic.GradeContrast, Float("grade-contrast", "Colour contrast", RenderSettingSemantic.GradeContrast,
1.0, 0.5, 2, 0.05), 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, 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, Float("sun-ray-strength", "Sun-ray strength", RenderSettingSemantic.SunRayStrength,
0.55, 0, 2, 0.05), 0.55, 0, 2, 0.05),
Float("sun-shadow-strength", "Directional-shadow strength", Float("sun-shadow-strength", "Directional-shadow strength",

View file

@ -5,6 +5,13 @@ layout(location = 0) out vec4 oColor;
#include "atmospheric_common.glsl" #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) vec3 acesFitted(vec3 value)
{ {
const float a = 2.51; const float a = 2.51;
@ -88,10 +95,9 @@ void main()
float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722)); float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));
color = mix(vec3(luminance), color, uPackParams0.y); color = mix(vec3(luminance), color, uPackParams0.y);
// 0.18 is linear mid-grey (the standard 18%-grey-card exposure // 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 // convention); the previous 0.5 was the [0,1] midpoint of the standard
// this same grey, pow(0.18, 1/2.2) ~= 0.459, rounded up for a stronger // contrast formula — in linear the perceptual mid-grey is 0.18, not 0.5.
// gamma-space contrast feel). Contrast now pivots around the correct // Contrast now pivots around the correct linear grey point.
// linear grey point.
const float LinearMidGrey = 0.18; const float LinearMidGrey = 0.18;
color = (color - LinearMidGrey) * uPackParams0.z + LinearMidGrey; color = (color - LinearMidGrey) * uPackParams0.z + LinearMidGrey;
@ -99,6 +105,6 @@ void main()
float vignette = smoothstep(1.25, 0.25, dot(centered, centered)); float vignette = smoothstep(1.25, 0.25, dot(centered, centered));
color *= mix(1.0, vignette, clamp(uPackParams0.w, 0.0, 1.0)); 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 // 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); oColor = vec4(acdreamEncodeDisplay(clamp(color, 0.0, 1.0)), 1.0);
} }

View file

@ -44,7 +44,7 @@
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "a08a297de0736e1798b7ea492c26de0fbdc509759a0423ae76260ac0c7d4a9ee", "sourceSha256": "dfc11d59f71d65efe650bcfa3434777cbcfe3358f5be38bf392f51b2bd2561b3",
"compiled": true "compiled": true
} }
] ]

View file

@ -45,7 +45,7 @@ internal readonly record struct VolumetricShaftFrameParameters(
int RayMarchSteps, int RayMarchSteps,
float Density, float Density,
float Strength, float Strength,
Vector3 LinearSunColor) Vector3 AuthoredSunColor)
{ {
internal static VolumetricShaftFrameParameters Disabled => internal static VolumetricShaftFrameParameters Disabled =>
new(false, 0f, 0, 0f, 0f, Vector3.Zero); new(false, 0f, 0, 0f, 0f, Vector3.Zero);
@ -105,7 +105,7 @@ internal static class VolumetricShaftPolicy
quality.RayMarchSteps, quality.RayMarchSteps,
Density: 0.035f * strength, Density: 0.035f * strength,
Strength: strength, Strength: strength,
LinearSunColor: color); AuthoredSunColor: color);
} }
private static float SmoothStep(float minimum, float maximum, float value) private static float SmoothStep(float minimum, float maximum, float value)

View file

@ -561,6 +561,10 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
{ {
Kind = LightKind.Directional, Kind = LightKind.Directional,
WorldForward = sunToWorld, 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, ColorLinear = keyframe.SunColor,
Intensity = 1f, Intensity = 1f,
Range = 1f, Range = 1f,

View file

@ -41,7 +41,19 @@ public sealed class LightSource
public LightKind Kind; public LightKind Kind;
public Vector3 WorldPosition; public Vector3 WorldPosition;
public Vector3 WorldForward; // for Spot/Directional 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 Intensity = 1f;
public float Range = 10f; // metres, hard cutoff public float Range = 10f; // metres, hard cutoff
public float ConeAngle = 0f; // radians, Spot only public float ConeAngle = 0f; // radians, Spot only

View file

@ -140,4 +140,21 @@ public sealed class AtmosphericColorPipelineTests
Assert.Equal(AtmosphericPostProcessGraph.BloomKneeLinear, derivedKnee, 2); Assert.Equal(AtmosphericPostProcessGraph.BloomKneeLinear, derivedKnee, 2);
Assert.Equal(1f, AtmosphericPostProcessGraph.BloomThresholdLinear); 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);
}
} }

View file

@ -1,3 +1,4 @@
using System.Globalization;
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.App.Plugins; using AcDream.App.Plugins;
@ -1180,6 +1181,7 @@ public sealed class AtmosphericPostProcessGraphTests
"AcDream.App", "AcDream.App",
"Rendering", "Rendering",
"Shaders"); "Shaders");
string common = File.ReadAllText(Path.Combine(shaderRoot, "atmospheric_common.glsl"));
string filmic = File.ReadAllText(Path.Combine(shaderRoot, "atmospheric_filmic.frag")); string filmic = File.ReadAllText(Path.Combine(shaderRoot, "atmospheric_filmic.frag"));
string downsample = File.ReadAllText( string downsample = File.ReadAllText(
Path.Combine(shaderRoot, "atmospheric_bloom_downsample.frag")); Path.Combine(shaderRoot, "atmospheric_bloom_downsample.frag"));
@ -1193,10 +1195,29 @@ public sealed class AtmosphericPostProcessGraphTests
filmic, filmic,
StringComparison.Ordinal); 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("); int decodesInDownsample = CountOccurrences(downsample, "acdreamDecodeDisplay(");
Assert.True( Assert.True(
decodesInDownsample >= 3, decodesInDownsample >= 3,
$"expected at least 3 acdreamDecodeDisplay( calls in atmospheric_bloom_downsample.frag, found {decodesInDownsample}"); $"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) private static int CountOccurrences(string haystack, string needle)