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>
This commit is contained in:
Erik 2026-08-22 22:39:58 +02:00
parent 388457a735
commit ae6513126e
11 changed files with 343 additions and 14 deletions

View file

@ -77,4 +77,33 @@ internal static class RetailDetailTextureContract
/// </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);
}
}

View file

@ -4,6 +4,7 @@
in vec2 vBaseUv;
in vec2 vDetailUv;
in float vDetailOpacity;
in vec3 vWorldPos;
in flat uint vBaseTextureIndex;
in flat uint vBaseTextureLayer;
in flat uint vBatchFlags;
@ -11,6 +12,42 @@ in flat uint vDetailCategory;
uniform uint uTextureIndexA; // category detail texture, layer 0
// SceneLighting UBO — IDENTICAL layout to mesh_modern.frag binding=1 (same
// std140 block, same struct, same binding). Declared here ONLY for fog
// (uFogParams/uFogColor/uCameraAndTime); mesh_detail never lights (no
// uLights[]/uCellAmbient read), it only needs applyFog below to match
// retail's fog placement exactly.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;
vec4 uFogColor;
vec4 uCameraAndTime;
};
// Copied verbatim from mesh_modern.frag — same math, not "improved". Retail's
// D3D fixed-function fog stage runs AFTER the texture-stage pipeline
// (RenderDeviceD3D's fog render state applies to the final pixel the
// blender produced, not to an individual texture stage's output), so the
// detail contribution must be fogged exactly like the base pass fogs its
// own colour, with the identical fog curve.
vec3 applyFog(vec3 lit, vec3 worldPos) {
int mode = int(uFogParams.w);
if (mode == 0) return lit;
float d = length(worldPos - uCameraAndTime.xyz);
float fogStart = uFogParams.x;
float fogEnd = uFogParams.y;
float span = max(1e-3, fogEnd - fogStart);
float fog = clamp((d - fogStart) / span, 0.0, 1.0);
return mix(lit, uFogColor.xyz, fog);
}
out vec4 FragColor;
// VM2 (2026-08-22, live cdb read on the PDB-paired retail client, GUID
@ -37,6 +74,20 @@ out vec4 FragColor;
// 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.
//
// Review fix (post-05970306): retail's texture-stage combine above happens
// BEFORE the D3D fixed-function fog stage, not after — fog is the LAST thing
// applied to the pixel, so it applies to the lerp's result, not to detail.rgb
// alone. mesh_modern.frag already fogs the base colour before this replay
// draws over it (applyFog(rgb, vWorldPos) there), so fogging detail.rgb here
// too makes the two-draw blend collapse to retail's single-draw order:
// (1-a)*mix(base,fog,f) + a*mix(detail,fog,f)
// = (1-f)*[(1-a)*base + a*detail] + f*fog
// = mix(lerp(base,detail,a), fog, f)
// which is exactly retail's fog-after-combine pixel. Leaving detail.rgb
// unfogged would draw detail at full saturation/brightness even at maximum
// fog distance. RetailDetailTextureContractTests pins this identity
// numerically (ExpectedFogged).
void main() {
// Object command replays may contain ordinary instances; only building
@ -56,5 +107,5 @@ void main() {
uTextureIndexA,
vec3(vDetailUv, 0.0));
FragColor = vec4(detail.rgb, detail.a * vDetailOpacity);
FragColor = vec4(applyFog(detail.rgb, vWorldPos), detail.a * vDetailOpacity);
}

View file

@ -70,6 +70,8 @@ uniform float uParamB; // 1 = require building instance, 0 = EnvCell category
out vec2 vBaseUv;
out vec2 vDetailUv;
out float vDetailOpacity;
out vec3 vWorldPos; // review fix: mesh_detail.frag needs this for applyFog,
// exactly like mesh_modern.vert's vWorldPos.
out flat uint vBaseTextureIndex;
out flat uint vBaseTextureLayer;
out flat uint vBatchFlags;
@ -80,6 +82,7 @@ void main() {
int instanceIndex = transformIndex - int(uTextureIndexB);
vec4 worldPos = Instances[transformIndex].transform * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos;
vWorldPos = worldPos.xyz;
uint slot = instanceClipSlot[instanceIndex];
CellClip clip = clipRegions[slot];

View file

@ -231,12 +231,12 @@
"stages": [
{
"stage": "vert",
"sourceSha256": "c955bdce56199ef2057dbaf0dc1d1f75ececac49047f57685b09b63117e921da",
"sourceSha256": "3b03a153a439aa54fcd0bcb575274c8e1da888b8ae85ee15fae020ccb1d3bf49",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "c02dd48647352a87c183fe925a9590b70731c677dd942bcea71ac6ad63ff486d",
"sourceSha256": "e037fd28cf71453792c17522c97c4c7797193823ed78ca629a4331b632f177b1",
"compiled": true
}
]

View file

@ -343,10 +343,12 @@ public sealed unsafe partial class EnvCellRenderer
}
// Retail DrawEnvCell category (2). Replay the already-filtered opaque
// shell commands, including ClipMap built-mesh subsets, and apply the
// 10-50 m positive-view-depth fade. The existing
// "Building Detail Textures" option gates both this and buildings,
// matching LScape::ChangeRegion.
// shell commands, including ClipMap built-mesh subsets. No distance
// fade (VM1/VM2): retail's noFadeDetail gates get_alpha_for_z to the
// immediate-polygon path only, which built meshes never reach;
// attenuation is the sampler's linear mip chain converging to the
// texture mean. The existing "Building Detail Textures" option gates
// both this and buildings, matching LScape::ChangeRegion.
if (renderPass == WbRenderPass.Opaque
&& detailEnabled)
{