acdream/src/AcDream.App/Rendering/RetailDetailTextureContract.cs
Erik ae6513126e fix(render): detail overlay is fogged after the combine like retail; VM1 review fixes
Opus dual-lens review of 05970306 + 388457a7 (APPROVE WITH FIXES). Four
items, all landed:

1. FOG (behavioural). Retail's D3D fixed-function fog stage runs AFTER the
   texture-stage pipeline, so the detail contribution must be fogged, not
   just the base. mesh_modern.frag already fogs the base colour
   (applyFog(rgb, vWorldPos)) before mesh_detail's replay draws over it;
   mesh_detail.frag previously emitted raw detail.rgb, understating fog by
   f*a*(fog-detail). Fix: mesh_detail.vert now outputs vWorldPos (mirroring
   mesh_modern.vert); mesh_detail.frag declares the identical SceneLighting
   UBO and applyFog function (copied verbatim, same binding/std140/math) and
   fogs detail.rgb before emitting it. This collapses algebraically to
   retail's fog-after-combine order:
     (1-a)*mix(base,fog,f) + a*mix(detail,fog,f) = mix(lerp(base,detail,a),fog,f)
   RetailDetailTextureContract gains ExpectedFogged(base,detail,opacity,fog,
   fogFactor); RetailDetailTextureContractTests pins the identity across 200
   random samples within 1e-6.

2. EnvCellRenderer.Rhi.cs's DrawEnvCell-category comment still said "apply
   the 10-50 m positive-view-depth fade" — a stale claim from before VM1
   removed the fade. Replaced with the mip-chain attenuation statement that
   mesh_detail.vert's header comment already carries.

3. Added the test the VM1 contract required but never had: TerrainAtlas
   .TryCreateDetailTexture uploads a full mip chain (MipLevelCount ==
   RhiWorldTextureArray.MipLevelsFor(w,h), GenerateMipChain called) and
   registers with the repeat/linear world sampler, not single-level or
   clamped. Drives the private method directly (reflection) against a
   synthetic PFID_A8R8G8B8 RenderSurface through a minimal in-memory
   IDatReaderWriter fake, so the lane stays hermetic (no installed DAT).

4. #226 pseudocode note: noted that retail's stage-1 OUTPUT alpha
   (MODULATE(TEXTURE, CURRENT), 0x0059c549) — the framebuffer blend weight a
   delayed-alpha subset composites with — is not modelled; acdream instead
   draws a second pass weighted by detail.a*diffuseAlpha. Identical for
   opaque subsets, a bounded difference on translucent building/EnvCell
   subsets already covered by the existing AP-34 shared-alpha-queue
   divergence row. Also qualified the tmpmaterial.Diffuse.a = 1f (0x0059cb99)
   citation to name its exact branch (burnedInStaticLights < 0 &&
   *(render_device+0x7e4) == 0); the other branch leaves diffuse FromVertex,
   but the opaque->1 / fading->opacity mapping still holds either way.

Nit also folded in: EnvCellRendererTests' new SubmitRhi instance-alpha test
is now a [Theory] over WbRenderPass.Opaque and .Transparent, pinning the
bind-before-first-draw invariant on both passes.

Regenerated mesh_detail's committed SPIR-V and the shader manifest
(tools/compile-shaders.ps1); no other shader pair changed.

Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests (Release, hermetic lanes) green, including
the shader manifest tests explicitly; AcDream.Core.Tests unaffected/green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:39:58 +02:00

109 lines
5.3 KiB
C#

using System.Numerics;
using AcDream.App.Rendering.Gpu;
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 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 static bool ShouldRender(
bool settingEnabled,
TerrainAtlas.RetailDetailTextureBinding binding) =>
settingEnabled && binding.IsAvailable;
/// <summary>
/// Opaque detail must compare equal against the depth written by its exact
/// base geometry. On an MSAA target that inherits the base pass's per-sample
/// alpha-to-coverage mask without applying A2C to the detail alpha itself.
/// Transparent bases do not write depth, so their adjacent detail uses the
/// accepted less-or-equal comparison instead.
/// </summary>
internal static GpuCompareOp DetailDepthCompare(bool transparent) =>
transparent ? GpuCompareOp.LessOrEqual : GpuCompareOp.Equal;
/// <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>
/// 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 bool IsNeutral(Vector4 detail, float opacity) =>
detail.W * opacity == 0f;
/// <summary>
/// Review fix (post-05970306): retail's D3D fog stage runs AFTER the
/// texture-stage combine, applying to the FINAL pixel, not to
/// <c>detail.rgb</c> in isolation. acdream draws the combine as two
/// separate passes (mesh_modern's base draw, then mesh_detail's blended
/// replay), so each draw fogs its OWN colour before the fixed-function
/// blend recombines them — this is the CPU statement of that two-draw
/// path: <c>lerp(mix(base,fog,f), mix(detail,fog,f), detail.a*opacity)</c>.
/// It is algebraically identical to retail's single-draw
/// fog-after-combine order, <c>mix(Expected(base,detail,opacity), fog,
/// f)</c> — see <c>RetailDetailTextureContractTests</c> for the identity
/// pinned numerically, and mesh_detail.frag's header comment for the
/// derivation.
/// </summary>
internal static Vector3 ExpectedFogged(
Vector3 baseColour,
Vector4 detail,
float opacity,
Vector3 fog,
float fogFactor)
{
Vector3 foggedBase = Vector3.Lerp(baseColour, fog, fogFactor);
Vector3 foggedDetail = Vector3.Lerp(
new Vector3(detail.X, detail.Y, detail.Z),
fog,
fogFactor);
return Vector3.Lerp(foggedBase, foggedDetail, detail.W * opacity);
}
}