acdream/src/AcDream.App/Rendering/VolumetricShaftQuality.cs
Erik 51178f7c77 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>
2026-08-22 23:40:48 +02:00

116 lines
4.2 KiB
C#

using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
internal readonly record struct VolumetricShaftQuality(
DirectionalShadowPreset Preset,
bool EnabledByDefault,
float ResolutionScale,
int RayMarchSteps,
double IncrementalGpuP50BudgetMilliseconds,
double IncrementalGpuP99BudgetMilliseconds)
{
internal static VolumetricShaftQuality For(DirectionalShadowPreset preset) =>
preset switch
{
DirectionalShadowPreset.Low => new(
preset,
EnabledByDefault: false,
ResolutionScale: 0.25f,
RayMarchSteps: 24,
IncrementalGpuP50BudgetMilliseconds: 0.15,
IncrementalGpuP99BudgetMilliseconds: 0.30),
DirectionalShadowPreset.Medium => new(
preset,
EnabledByDefault: true,
ResolutionScale: 0.25f,
RayMarchSteps: 40,
IncrementalGpuP50BudgetMilliseconds: 0.25,
IncrementalGpuP99BudgetMilliseconds: 0.40),
DirectionalShadowPreset.High => new(
preset,
EnabledByDefault: true,
ResolutionScale: 0.50f,
RayMarchSteps: 56,
IncrementalGpuP50BudgetMilliseconds: 0.40,
IncrementalGpuP99BudgetMilliseconds: 0.65),
_ => throw new ArgumentOutOfRangeException(nameof(preset), preset, null),
};
}
internal readonly record struct VolumetricShaftFrameParameters(
bool Enabled,
float ResolutionScale,
int RayMarchSteps,
float Density,
float Strength,
Vector3 AuthoredSunColor)
{
internal static VolumetricShaftFrameParameters Disabled =>
new(false, 0f, 0, 0f, 0f, Vector3.Zero);
}
/// <summary>
/// Maps authored AC sun/weather inputs and the already-gated directional map
/// to pack-local volumetric parameters. It owns no clock or weather state and
/// cannot enable shafts without the current frame's valid shadow map.
/// </summary>
internal static class VolumetricShaftPolicy
{
internal static VolumetricShaftFrameParameters Evaluate(
in VolumetricShaftQuality quality,
bool userEnabled,
in DirectionalShadowEnvironmentState shadow,
WeatherKind weather,
Vector3 authoredSunColor,
float authoredSunBrightness)
{
if (!userEnabled
|| !shadow.ShouldRender
|| shadow.SourceKind is not Packs.AuthoredCelestialShadowSourceKind.Sun
|| !float.IsFinite(authoredSunBrightness)
|| authoredSunBrightness <= 0f)
return VolumetricShaftFrameParameters.Disabled;
float weatherMultiplier = weather switch
{
WeatherKind.Clear => 1f,
WeatherKind.Overcast => 0.18f,
WeatherKind.Rain => 0.10f,
WeatherKind.Snow => 0.16f,
WeatherKind.Storm => 0.06f,
_ => 0f,
};
if (weatherMultiplier <= 0f)
return VolumetricShaftFrameParameters.Disabled;
// Peak at a raking but valid sun. The shadow gate already fades the
// first degrees above the horizon; this term then rolls shafts away
// toward noon without inventing a time-of-day schedule.
float elevation = Math.Clamp(shadow.LightElevationSin, 0f, 1f);
float noonRollOff = 1f - SmoothStep(0.18f, 0.82f, elevation);
float strength = Math.Clamp(
shadow.Strength * weatherMultiplier * noonRollOff,
0f,
1f);
if (strength <= 1e-4f)
return VolumetricShaftFrameParameters.Disabled;
Vector3 color = Vector3.Max(Vector3.Zero, authoredSunColor)
* Math.Clamp(authoredSunBrightness, 0f, 8f);
return new VolumetricShaftFrameParameters(
Enabled: true,
quality.ResolutionScale,
quality.RayMarchSteps,
Density: 0.035f * strength,
Strength: strength,
AuthoredSunColor: color);
}
private static float SmoothStep(float minimum, float maximum, float value)
{
float t = Math.Clamp((value - minimum) / (maximum - minimum), 0f, 1f);
return t * t * (3f - (2f * t));
}
}