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,