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)