feat(render): Atmospheric post stack runs in linear light; neutral preset is numerically the pack-off image (Campaign VM VM3)

Closes review finding F4 (docs/research/2026-08-22-campaign-ar-review.md):
retail's main-world colour, sun rays and volumetric shafts are all
gamma-encoded display-space values (the 2013 client has no linear
lighting pipeline), but bloom thresholding, ACES (Narkowicz fit),
Rec.709 luma saturation, the contrast pivot and the vignette were all
operating directly on those gamma values, then writing the result to
the UNORM swapchain without re-encoding.

- atmospheric_common.glsl gains acdreamDecodeDisplay/acdreamEncodeDisplay
  (pow(c, 2.2) / pow(c, 1/2.2)). 2.2 is the retail-era CRT/early-LCD
  display-gamma assumption, deliberately not the sRGB piecewise curve,
  which would claim a precision retail's authoring pipeline never had.
  uAtmosphereSunColor's comment is corrected from "authored linear rgb"
  to "authored display-space rgb (retail has no linear pipeline)".
- atmospheric_bloom_downsample.frag, atmospheric_filmic.frag (both the
  fused-Low and non-fused paths) decode every world/ray/volumetric read
  before summing/thresholding; atmospheric_bloom_blur.frag is unchanged
  (it already reads the now-linear bloom buffer); atmospheric_sun_rays.frag
  and atmospheric_volumetric.frag are documented as writing display-space
  colour that the consumers decode.
- The contrast pivot moves from 0.5 (a gamma-space midpoint) to 0.18
  (linear mid-grey, the standard 18%-grey-card exposure convention).
  The final filmic output is clamped in linear, then re-encoded before
  the UNORM write.
- Bloom threshold/knee are re-derived for linear light: the pre-VM3
  gamma-space pair was threshold 1.0 / knee 0.45, i.e. a soft range of
  [0.55, 1.0] in gamma. Decoding both ends with the same 2.2 assumption
  gives decode(1.0) = 1.0 (threshold unchanged) and
  decode(0.55) = 0.55^2.2 ~= 0.27, so linear knee = 1.0 - 0.27 ~= 0.73.
  Replaced the inline 0.45f literals with named constants
  BloomThresholdLinear = 1f / BloomKneeLinear = 0.73f on
  AtmosphericPostProcessGraph. bloom-strength's 0.65 default is
  untouched.
- Exposure stays at its accepted 0.80 default: in linear,
  encode(acesFitted(0.80 * decode(0.46))) ~= 0.50, reproducing the same
  accepted midtone the old gamma-space pipeline produced as 0.51 for the
  same 0.46 input (0.46 * 0.80 fed straight into acesFitted, no
  decode/encode). Highlights now retain more (gamma 0.9 input moves from
  ~0.74 to ~0.85 through the full pipeline) and blacks deepen slightly
  (gamma 0.1 moves from ~0.09 to ~0.05) — the owner's visual gate judges.
- Added AtmosphericColorPipeline, a CPU mirror of the GLSL decode/encode/
  ACES/grade/filmic math (line-for-line, with a header comment requiring
  it stay mirrored), and AtmosphericColorPipelineTests: neutral-preset
  identity within half an 8-bit step for a 0..255 grey sweep (proving the
  neutral preset is numerically the pack-off image), decode/encode
  round-trip within 1e-6, monotonic-in-exposure, the pinned midtone/
  highlight/shadow numbers above, and the bloom-knee derivation.
- Added a shader-source pinning test so a future edit cannot silently
  drop the colour-space conversions: atmospheric_filmic.frag must
  contain exactly one acdreamEncodeDisplay( call in main()'s output,
  atmospheric_bloom_downsample.frag must contain at least three
  acdreamDecodeDisplay( calls.
- Regenerated SPIR-V (tools/compile-shaders.ps1, glslc from the
  installed Vulkan SDK). Only atmospheric_bloom_downsample.frag.spv and
  atmospheric_filmic.frag.spv changed in bytes; every other pack shader
  that includes atmospheric_common.glsl recompiled to a byte-identical
  binary (the new decode/encode helpers are unreferenced dead code for
  them). VulkanShaderManifestTests' retail-oracle SHA-256 set
  (mesh_modern, terrain_modern, mesh_detail, etc.) is untouched and
  still passes — the retail default path did not change.
- Docs: noted the linear-light move in the AR plan's Slice 1 section,
  and added a "Colour space" section to the render-pack ABI doc
  (docs/render-packs/semantic-bindings-v1.md) naming which inputs are
  display-space and pointing at atmospheric_common.glsl as the
  reference implementation. No ABI version bump — the binding layout
  is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-22 23:10:57 +02:00
parent b1e9912535
commit 87677f9c4f
16 changed files with 410 additions and 24 deletions

View file

@ -0,0 +1,143 @@
using System.Numerics;
using AcDream.App.Rendering.Packs;
namespace AcDream.App.Tests.Rendering.Packs;
/// <summary>
/// Campaign VM VM3: pins <see cref="AtmosphericColorPipeline"/>, 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.
/// </summary>
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);
}
}

View file

@ -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,