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

@ -3,7 +3,7 @@
in vec2 vBaseUv;
in vec2 vDetailUv;
in float vDetailFade;
in float vDetailOpacity;
in flat uint vBaseTextureIndex;
in flat uint vBaseTextureLayer;
in flat uint vBatchFlags;
@ -13,6 +13,31 @@ uniform uint uTextureIndexA; // category detail texture, layer 0
out vec4 FragColor;
// VM2 (2026-08-22, live cdb read on the PDB-paired retail client, GUID
// 9e847e2f-777c-4bd9-886c-22256bb87f32): retail's RenderDevice reports
// m_caps.bCanDoSinglePassDetailing = 1 and the file-static trysinglepass = 1,
// so real hardware never takes the two-pass framebuffer fallback this shader
// used to reproduce. It takes the single-pass texture-stage combine set up
// in D3DPolyRender::SetSurface (0x0059c4d0) and consumed per built-mesh
// material subset by D3DPolyRender::RenderMeshSubset (0x0059ca10):
//
// stage 0 colour = MODULATE(TEXTURE, DIFFUSE) = base.rgb * diffuse.rgb
// stage 0 alpha = PREMODULATE(DIFFUSE, DIFFUSE) = diffuse.a * detail.a
// stage 1 colour = BLENDCURRENTALPHA(TEXTURE, CURRENT) = lerp(current.rgb, detail.rgb, stage0.a)
//
// i.e. the pixel retail draws is lerp(base * diffuse, detail.rgb, detail.a *
// diffuse.a) — a blend TOWARD the detail colour by detail.a * diffuse.a, not
// a "dest * (detail + 1 - alpha)" brightening. RenderMeshSubset lights every
// built mesh with tmpmaterial.Diffuse.a = 1 (opaque subsets), so
// vDetailOpacity below is exactly that diffuse.a — 1 for opaque, the
// translucency-fade multiplier for a fading subset. The pipeline blend
// (VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail)) expresses
// the same lerp as SrcAlpha + OneMinusSrcAlpha over the existing base pixel,
// so this fragment outputs the raw (non-premultiplied) detail colour with
// that combined alpha and lets the fixed-function blend unit do the
// base*(1-a) + detail*a lerp. See
// docs/research/2026-08-22-vm2-retail-detail-path-cdb.md.
void main() {
// Object command replays may contain ordinary instances; only building
// shells survive. Bit 0 means this command came through retail's built-mesh
@ -20,8 +45,6 @@ void main() {
// curr_detail_surface for every built-mesh material subset.
if (vDetailCategory == 0u || (vBatchFlags & 1u) == 0u)
discard;
if (vDetailFade <= 0.0)
discard;
vec4 base = ACDREAM_SAMPLE_ARRAY(
vBaseTextureIndex,
@ -33,10 +56,5 @@ void main() {
uTextureIndexA,
vec3(vDetailUv, 0.0));
// Pipeline blend is retail's DstColor + OneMinusSrcAlpha. Scaling both
// source colour and alpha makes fade=0 exactly neutral while fade=1 keeps
// retail's measured factor: dest * (detail.rgb + 1 - detail.a).
FragColor = vec4(
detail.rgb * vDetailFade,
detail.a * vDetailFade);
FragColor = vec4(detail.rgb, detail.a * vDetailOpacity);
}

View file

@ -44,6 +44,18 @@ layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
uint instanceDetailCategory[];
};
// #188 per-instance opacity multiplier, identical binding and indexing to
// mesh_modern.vert's InstanceAlphaBuf (binding 7). VM2's cdb read proved
// retail's built-mesh detail combine is a single-pass texture-stage blend
// whose stage-0 alpha is PREMODULATE(DIFFUSE, DIFFUSE) = diffuse.a *
// detail.a (D3DPolyRender::SetSurface 0x0059c4d0) — the base subset's own
// diffuse alpha gates how much detail shows through, exactly like the base
// pass's translucency-fade multiplier already does for mesh_modern. 1.0 for
// every opaque subset; <1.0 while a TransparentPartHook fade is in flight.
layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
float instanceAlpha[];
};
out gl_PerVertex {
vec4 gl_Position;
float gl_ClipDistance[8];
@ -57,7 +69,7 @@ uniform float uParamB; // 1 = require building instance, 0 = EnvCell category
out vec2 vBaseUv;
out vec2 vDetailUv;
out float vDetailFade;
out float vDetailOpacity;
out flat uint vBaseTextureIndex;
out flat uint vBaseTextureLayer;
out flat uint vBatchFlags;
@ -76,11 +88,14 @@ void main() {
for (uint i = clip.count; i < 8u; ++i)
gl_ClipDistance[i] = 1.0;
// System.Numerics' perspective projection used by every gameplay camera
// makes clip.w the positive view-space depth. Retail get_alpha_for_z uses
// that same metric in metres: 255 through 10 m, linearly to 0 at 50 m.
float positiveViewDepthMetres = gl_Position.w;
vDetailFade = clamp((50.0 - positiveViewDepthMetres) / 40.0, 0.0, 1.0);
// No distance term: VM2 found the fade only exists in
// D3DPolyRender::DrawPolyInternal (0x0059d7c0, the immediate-polygon
// path) and only when the static noFadeDetail (0x00820e38, initialised
// to 1) is 0. Every loaded CGfxObj sets use_built_mesh=1
// (CGfxObj::InitLoad 0x005346b0), so buildings/EnvCells never reach that
// function; their attenuation is the LINEAR mip chain converging to the
// texture mean, which the existing sampler already provides.
vDetailOpacity = instanceAlpha[instanceIndex];
vBaseUv = aTexCoord;
vDetailUv = aTexCoord * uParamA;

View file

@ -231,12 +231,12 @@
"stages": [
{
"stage": "vert",
"sourceSha256": "0273312da9fefb5084d3aee120f33c542e1adcfb846e6c7b3fc2b068c1348fb3",
"sourceSha256": "c955bdce56199ef2057dbaf0dc1d1f75ececac49047f57685b09b63117e921da",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "1fbc3ffb12d260cbe0d111551f28c69f15754ba5e5e11fe3ec43747bebef4883",
"sourceSha256": "c02dd48647352a87c183fe925a9590b70731c677dd942bcea71ac6ad63ff486d",
"compiled": true
}
]