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

@ -45,7 +45,15 @@ internal static class AtmosphericColorPipeline
internal static Vector3 Encode(Vector3 c) =>
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)
{
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.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));
}

View file

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

View file

@ -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);
}

View file

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

View file

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

View file

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