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)