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:
parent
87677f9c4f
commit
51178f7c77
12 changed files with 151 additions and 21 deletions
|
|
@ -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.
|
slice meets its preset GPU/VRAM budget.
|
||||||
|
|
||||||
Campaign VM VM3 (2026-08-22) moved the post stack to linear light: every
|
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,
|
world/ray/volumetric read is decoded with the 2.2 display assumption; the
|
||||||
tonemap/grade/vignette run in linear, and the result is re-encoded; the
|
bloom intermediate is already linear (decoded once at extraction, so
|
||||||
neutral preset is numerically the pack-off image.
|
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
|
### Slice 2 — Tier-1 screen-space sun rays
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -210,13 +210,55 @@ compensation.
|
||||||
vignette 0) — the existing "every effect can be set to neutral" acceptance
|
vignette 0) — the existing "every effect can be set to neutral" acceptance
|
||||||
now actually holds numerically, and is asserted by a test.
|
now actually holds numerically, and is asserted by a test.
|
||||||
- Opinionated defaults (what the owner accepted at 0.80 exposure) are
|
- Opinionated defaults (what the owner accepted at 0.80 exposure) are
|
||||||
re-derived in linear and presented for the visual gate; expect exposure to
|
re-derived in linear and presented for the visual gate. **Shipped truth
|
||||||
return to ~1.0 and the bloom threshold to move up.
|
(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
|
Old-vs-new curve at the shipped exposure 0.80 (gamma input →
|
||||||
test on the recording RHI plus VM0's masked tool on a real capture); Stage-1
|
old-pipeline display value / new-pipeline display value; filmic
|
||||||
luminance table re-captured; owner visual gate: "same look as accepted, no
|
strength 1, saturation/contrast 1, no vignette — i.e. `acesFitted`
|
||||||
clipping, highlights roll off". Budget unchanged (two `pow` per pixel).
|
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)
|
## VM4 — Truthful documents (F5)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,15 @@ internal static class AtmosphericColorPipeline
|
||||||
internal static Vector3 Encode(Vector3 c) =>
|
internal static Vector3 Encode(Vector3 c) =>
|
||||||
Pow(Vector3.Max(c, Vector3.Zero), 1f / DisplayGamma);
|
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)
|
internal static Vector3 AcesFitted(Vector3 value)
|
||||||
{
|
{
|
||||||
const float a = 2.51f;
|
const float a = 2.51f;
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ internal readonly record struct AtmosphericPostProcessSettings(
|
||||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.Exposure, 1f),
|
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.Exposure, 1f),
|
||||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.GradeSaturation, 1f),
|
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.GradeSaturation, 1f),
|
||||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.GradeContrast, 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));
|
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.SunRayStrength, 0.55f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -268,8 +268,25 @@ internal static class BuiltInAtmosphericRenderPack
|
||||||
1.0, 0, 2, 0.05),
|
1.0, 0, 2, 0.05),
|
||||||
Float("grade-contrast", "Colour contrast", RenderSettingSemantic.GradeContrast,
|
Float("grade-contrast", "Colour contrast", RenderSettingSemantic.GradeContrast,
|
||||||
1.0, 0.5, 2, 0.05),
|
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,
|
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,
|
Float("sun-ray-strength", "Sun-ray strength", RenderSettingSemantic.SunRayStrength,
|
||||||
0.55, 0, 2, 0.05),
|
0.55, 0, 2, 0.05),
|
||||||
Float("sun-shadow-strength", "Directional-shadow strength",
|
Float("sun-shadow-strength", "Directional-shadow strength",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@ layout(location = 0) out vec4 oColor;
|
||||||
|
|
||||||
#include "atmospheric_common.glsl"
|
#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)
|
vec3 acesFitted(vec3 value)
|
||||||
{
|
{
|
||||||
const float a = 2.51;
|
const float a = 2.51;
|
||||||
|
|
@ -88,10 +95,9 @@ void main()
|
||||||
float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
||||||
color = mix(vec3(luminance), color, uPackParams0.y);
|
color = mix(vec3(luminance), color, uPackParams0.y);
|
||||||
// 0.18 is linear mid-grey (the standard 18%-grey-card exposure
|
// 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
|
// convention); the previous 0.5 was the [0,1] midpoint of the standard
|
||||||
// this same grey, pow(0.18, 1/2.2) ~= 0.459, rounded up for a stronger
|
// contrast formula — in linear the perceptual mid-grey is 0.18, not 0.5.
|
||||||
// gamma-space contrast feel). Contrast now pivots around the correct
|
// Contrast now pivots around the correct linear grey point.
|
||||||
// linear grey point.
|
|
||||||
const float LinearMidGrey = 0.18;
|
const float LinearMidGrey = 0.18;
|
||||||
color = (color - LinearMidGrey) * uPackParams0.z + LinearMidGrey;
|
color = (color - LinearMidGrey) * uPackParams0.z + LinearMidGrey;
|
||||||
|
|
||||||
|
|
@ -99,6 +105,6 @@ void main()
|
||||||
float vignette = smoothstep(1.25, 0.25, dot(centered, centered));
|
float vignette = smoothstep(1.25, 0.25, dot(centered, centered));
|
||||||
color *= mix(1.0, vignette, clamp(uPackParams0.w, 0.0, 1.0));
|
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
|
// 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);
|
oColor = vec4(acdreamEncodeDisplay(clamp(color, 0.0, 1.0)), 1.0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"stage": "frag",
|
"stage": "frag",
|
||||||
"sourceSha256": "a08a297de0736e1798b7ea492c26de0fbdc509759a0423ae76260ac0c7d4a9ee",
|
"sourceSha256": "dfc11d59f71d65efe650bcfa3434777cbcfe3358f5be38bf392f51b2bd2561b3",
|
||||||
"compiled": true
|
"compiled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ internal readonly record struct VolumetricShaftFrameParameters(
|
||||||
int RayMarchSteps,
|
int RayMarchSteps,
|
||||||
float Density,
|
float Density,
|
||||||
float Strength,
|
float Strength,
|
||||||
Vector3 LinearSunColor)
|
Vector3 AuthoredSunColor)
|
||||||
{
|
{
|
||||||
internal static VolumetricShaftFrameParameters Disabled =>
|
internal static VolumetricShaftFrameParameters Disabled =>
|
||||||
new(false, 0f, 0, 0f, 0f, Vector3.Zero);
|
new(false, 0f, 0, 0f, 0f, Vector3.Zero);
|
||||||
|
|
@ -105,7 +105,7 @@ internal static class VolumetricShaftPolicy
|
||||||
quality.RayMarchSteps,
|
quality.RayMarchSteps,
|
||||||
Density: 0.035f * strength,
|
Density: 0.035f * strength,
|
||||||
Strength: strength,
|
Strength: strength,
|
||||||
LinearSunColor: color);
|
AuthoredSunColor: color);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float SmoothStep(float minimum, float maximum, float value)
|
private static float SmoothStep(float minimum, float maximum, float value)
|
||||||
|
|
|
||||||
|
|
@ -561,6 +561,10 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
|
||||||
{
|
{
|
||||||
Kind = LightKind.Directional,
|
Kind = LightKind.Directional,
|
||||||
WorldForward = sunToWorld,
|
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,
|
ColorLinear = keyframe.SunColor,
|
||||||
Intensity = 1f,
|
Intensity = 1f,
|
||||||
Range = 1f,
|
Range = 1f,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,19 @@ public sealed class LightSource
|
||||||
public LightKind Kind;
|
public LightKind Kind;
|
||||||
public Vector3 WorldPosition;
|
public Vector3 WorldPosition;
|
||||||
public Vector3 WorldForward; // for Spot/Directional
|
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 Intensity = 1f;
|
||||||
public float Range = 10f; // metres, hard cutoff
|
public float Range = 10f; // metres, hard cutoff
|
||||||
public float ConeAngle = 0f; // radians, Spot only
|
public float ConeAngle = 0f; // radians, Spot only
|
||||||
|
|
|
||||||
|
|
@ -140,4 +140,21 @@ public sealed class AtmosphericColorPipelineTests
|
||||||
Assert.Equal(AtmosphericPostProcessGraph.BloomKneeLinear, derivedKnee, 2);
|
Assert.Equal(AtmosphericPostProcessGraph.BloomKneeLinear, derivedKnee, 2);
|
||||||
Assert.Equal(1f, AtmosphericPostProcessGraph.BloomThresholdLinear);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Globalization;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using AcDream.App.Plugins;
|
using AcDream.App.Plugins;
|
||||||
|
|
@ -1180,6 +1181,7 @@ public sealed class AtmosphericPostProcessGraphTests
|
||||||
"AcDream.App",
|
"AcDream.App",
|
||||||
"Rendering",
|
"Rendering",
|
||||||
"Shaders");
|
"Shaders");
|
||||||
|
string common = File.ReadAllText(Path.Combine(shaderRoot, "atmospheric_common.glsl"));
|
||||||
string filmic = File.ReadAllText(Path.Combine(shaderRoot, "atmospheric_filmic.frag"));
|
string filmic = File.ReadAllText(Path.Combine(shaderRoot, "atmospheric_filmic.frag"));
|
||||||
string downsample = File.ReadAllText(
|
string downsample = File.ReadAllText(
|
||||||
Path.Combine(shaderRoot, "atmospheric_bloom_downsample.frag"));
|
Path.Combine(shaderRoot, "atmospheric_bloom_downsample.frag"));
|
||||||
|
|
@ -1193,10 +1195,29 @@ public sealed class AtmosphericPostProcessGraphTests
|
||||||
filmic,
|
filmic,
|
||||||
StringComparison.Ordinal);
|
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(");
|
int decodesInDownsample = CountOccurrences(downsample, "acdreamDecodeDisplay(");
|
||||||
Assert.True(
|
Assert.True(
|
||||||
decodesInDownsample >= 3,
|
decodesInDownsample >= 3,
|
||||||
$"expected at least 3 acdreamDecodeDisplay( calls in atmospheric_bloom_downsample.frag, found {decodesInDownsample}");
|
$"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)
|
private static int CountOccurrences(string haystack, string needle)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue