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:
parent
30e72a2af8
commit
059703066f
13 changed files with 266 additions and 128 deletions
|
|
@ -137,10 +137,13 @@ internal enum GpuBlendMode
|
|||
InverseAlpha,
|
||||
|
||||
/// <summary>
|
||||
/// Retail building/EnvCell detail overlay:
|
||||
/// <c>DstColor, OneMinusSrcAlpha</c>. This intentionally preserves the
|
||||
/// retail client's measured brightening; it is not a conventional
|
||||
/// modulate/roughening blend.
|
||||
/// Retail building/EnvCell detail overlay: <c>SrcAlpha,
|
||||
/// OneMinusSrcAlpha</c>. VM2's live cdb read proved retail hardware runs
|
||||
/// the single-pass texture-stage combine (<c>D3DPolyRender::SetSurface</c>
|
||||
/// 0x0059c4d0), a lerp toward the detail colour by
|
||||
/// <c>detail.a * diffuse.a</c> — a mild darkening on the live Dereth
|
||||
/// category texture, not the two-pass <c>DstColor</c> fallback's
|
||||
/// brightening.
|
||||
/// </summary>
|
||||
RetailDetail,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,9 +175,12 @@ internal static class VulkanViewportMapping
|
|||
GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One),
|
||||
// Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend.
|
||||
GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
|
||||
// ACRender::SetDetailSurfaceInternal with DrawBuilding/DrawEnvCell's
|
||||
// category state: D3DBLEND_DESTCOLOR + D3DBLEND_INVSRCALPHA.
|
||||
GpuBlendMode.RetailDetail => (BlendFactor.DstColor, BlendFactor.OneMinusSrcAlpha),
|
||||
// VM2 (live cdb read, 2026-08-22): real hardware runs the single-pass
|
||||
// texture-stage detail combine, not the two-pass DESTCOLOR fallback.
|
||||
// D3DPolyRender::SetSurface's stage-1 BLENDCURRENTALPHA is a lerp
|
||||
// toward the detail colour by detail.a * diffuse.a, which is the
|
||||
// ordinary straight-alpha-over factor pair.
|
||||
GpuBlendMode.RetailDetail => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
|
||||
GpuBlendMode.None => (BlendFactor.One, BlendFactor.Zero),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(blend), blend, "Unknown blend mode."),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// (≈ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -231,12 +231,12 @@
|
|||
"stages": [
|
||||
{
|
||||
"stage": "vert",
|
||||
"sourceSha256": "0273312da9fefb5084d3aee120f33c542e1adcfb846e6c7b3fc2b068c1348fb3",
|
||||
"sourceSha256": "c955bdce56199ef2057dbaf0dc1d1f75ececac49047f57685b09b63117e921da",
|
||||
"compiled": true
|
||||
},
|
||||
{
|
||||
"stage": "frag",
|
||||
"sourceSha256": "1fbc3ffb12d260cbe0d111551f28c69f15754ba5e5e11fe3ec43747bebef4883",
|
||||
"sourceSha256": "c02dd48647352a87c183fe925a9590b70731c677dd942bcea71ac6ad63ff486d",
|
||||
"compiled": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue