fix(render): #226 detail overlay uses retail's single-pass combine; drop the dead distance fade (Campaign VM VM1)

VM2's live cdb read against the PDB-paired retail client (GUID
9e847e2f-777c-4bd9-886c-22256bb87f32) proved
m_caps.bCanDoSinglePassDetailing = 1 and trysinglepass = 1 on real hardware,
so D3DPolyRender::RenderMeshSubset (0x0059ca10) never falls back to the
two-pass framebuffer blend the earlier #226 port reproduced. Every loaded
CGfxObj sets use_built_mesh = 1 (CGfxObj::InitLoad 0x005346b0), so buildings
and EnvCells always take the single-pass texture-stage combine set up in
D3DPolyRender::SetSurface (0x0059c4d0):

    result = lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)

RenderMeshSubset lights opaque built-mesh subsets with
tmpmaterial.Diffuse.a = 1, so on the live Dereth category texture
0x06006D58 (mean rgb 0.165, mean alpha 0.132) the combine works out to
~0.868 * base + 0.022 — a mild darkening, the opposite sign of the fallback
DstColor blend's brightening.

Also removes the invented 10 m / 50 m distance fade. Retail's
ACRender::get_alpha_for_z (0x006b6230) is only evaluated in
D3DPolyRender::DrawPolyInternal (0x0059d7c0, the immediate-polygon path)
and only when the static noFadeDetail (0x00820e38, initialised to 1) is 0 —
unreachable for built meshes. Attenuation is the sampler's linear mip chain
converging to the texture mean, not a scripted ramp.

Changes:
- mesh_detail.vert/.frag: drop vDetailFade and its distance term; add
  vDetailOpacity mirroring mesh_modern.vert's InstanceAlphaBuf (binding 7)
  read, and output detail.rgb with alpha = detail.a * vDetailOpacity under
  the corrected pipeline blend.
- VulkanViewportMapping.BlendFactorsOf / GpuEnums.GpuBlendMode.RetailDetail:
  SrcAlpha + OneMinusSrcAlpha instead of DstColor + OneMinusSrcAlpha.
- RetailDetailTextureContract: replaced the distance-fade constants and
  FramebufferFactor with Expected(base, detail, opacity) and IsNeutral,
  matching the lerp; contract tests cover zero-alpha/zero-opacity no-ops,
  the measured darkening on the live category texture, and full-alpha
  replacement.
- Regenerated mesh_detail's committed SPIR-V and the shader manifest
  (tools/compile-shaders.ps1); no other shader pair changed.
- Docs: #226's pseudocode note, the docs/ISSUES.md #226 entry, and the
  retired TS-52 divergence-register row corrected from the two-pass
  DESTCOLOR description to the single-pass path and the darkening
  expectation, each citing the VM2 cdb note.

Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests and AcDream.Core.Tests (Release, hermetic
lanes) both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-22 21:58:48 +02:00
parent 30e72a2af8
commit 059703066f
13 changed files with 266 additions and 128 deletions

View file

@ -5,15 +5,39 @@ namespace AcDream.App.Rendering;
/// <summary>
/// Testable CPU statement of retail's detail-pass gate and pixel math. The
/// production pixels are produced by <c>mesh_detail</c>; keeping these facts in
/// one small contract makes the setting, distance units, neutral point, and
/// intentional brightening independently assertable without a GPU.
/// production pixels are produced by <c>mesh_detail</c>; keeping these facts
/// in one small contract makes the setting and the combine independently
/// assertable without a GPU.
///
/// <para>VM2 (2026-08-22, live cdb read on the PDB-paired retail client,
/// <c>docs/research/2026-08-22-vm2-retail-detail-path-cdb.md</c>) settled
/// which of retail's two detail paths real hardware runs. Retail's
/// <c>RenderDevice::render_device.m_caps.bCanDoSinglePassDetailing</c> reads
/// 1 and the file-static <c>trysinglepass</c> reads 1, so
/// <c>D3DPolyRender::RenderMeshSubset</c> (0x0059ca10) never falls back to
/// the two-pass framebuffer blend the earlier #226 port reproduced; it takes
/// the single-pass texture-stage combine set up in
/// <c>D3DPolyRender::SetSurface</c> (0x0059c4d0):
/// <c>lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)</c> — a blend
/// TOWARD the detail colour by <c>detail.a * diffuse.a</c>, not the
/// fallback's <c>dest * (detail.rgb + 1 - detail.a)</c>. Built meshes light
/// with <c>tmpmaterial.Diffuse.a = 1</c> for opaque subsets
/// (<c>RenderMeshSubset</c>), so on the live Dereth category texture (mean
/// rgb 0.165, mean alpha 0.132) the combine is a mild darkening
/// (&#8776; 0.868 * base + 0.022), the opposite sign of the fallback's
/// brightening.</para>
///
/// <para>There is no distance fade on this path. Retail's
/// <c>ACRender::get_alpha_for_z</c> (0x006b6230) is only evaluated in
/// <c>D3DPolyRender::DrawPolyInternal</c> (0x0059d7c0, the immediate-polygon
/// path) and only when the static <c>noFadeDetail</c> (0x00820e38,
/// initialised to 1) is 0. Every loaded <c>CGfxObj</c> sets
/// <c>use_built_mesh=1</c> (<c>CGfxObj::InitLoad</c> 0x005346b0), so buildings
/// and EnvCells never reach that function — their attenuation is the LINEAR
/// mip chain converging to the texture mean, not a scripted ramp.</para>
/// </summary>
internal static class RetailDetailTextureContract
{
internal const float FullDetailDistanceMetres = 10f;
internal const float ZeroDetailDistanceMetres = 50f;
internal static bool ShouldRender(
bool settingEnabled,
TerrainAtlas.RetailDetailTextureBinding binding) =>
@ -29,19 +53,28 @@ internal static class RetailDetailTextureContract
internal static GpuCompareOp DetailDepthCompare(bool transparent) =>
transparent ? GpuCompareOp.LessOrEqual : GpuCompareOp.Equal;
internal static float FadeForPositiveViewDepthMetres(float depthMetres) =>
Math.Clamp(
(ZeroDetailDistanceMetres - depthMetres)
/ (ZeroDetailDistanceMetres - FullDetailDistanceMetres),
0f,
1f);
/// <summary>
/// The exact pixel <c>mesh_detail</c> composites onto the existing
/// framebuffer colour: retail's single-pass stage-1
/// <c>BLENDCURRENTALPHA(TEXTURE, CURRENT)</c>, a lerp from
/// <paramref name="baseColour"/> toward <paramref name="detail"/>'s RGB by
/// <c>detail.a * opacity</c>. <paramref name="opacity"/> is the base
/// subset's diffuse alpha — 1 for an opaque subset, the translucency-fade
/// multiplier for a fading one — mirrored from the shader's
/// <c>instanceAlpha[instanceIndex]</c> read.
/// </summary>
internal static Vector3 Expected(Vector3 baseColour, Vector4 detail, float opacity) =>
Vector3.Lerp(
baseColour,
new Vector3(detail.X, detail.Y, detail.Z),
detail.W * opacity);
/// <summary>
/// Effective multiplier on the existing framebuffer after the shader
/// scales both detail RGB and alpha by fade and the pipeline applies
/// <c>DstColor + OneMinusSrcAlpha</c>.
/// True when the combine above is an exact no-op — either the detail
/// texel is fully transparent or the base subset's own diffuse alpha (the
/// translucency fade) has reached zero. Neutral is <c>detail.a * opacity
/// == 0</c>, not any particular colour equality.
/// </summary>
internal static Vector3 FramebufferFactor(Vector4 detail, float fade) =>
Vector3.One + fade * (new Vector3(detail.X, detail.Y, detail.Z)
- new Vector3(detail.W));
internal static bool IsNeutral(Vector4 detail, float opacity) =>
detail.W * opacity == 0f;
}