diff --git a/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md b/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md index 0bf362d9..9ea8f024 100644 --- a/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md +++ b/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md @@ -105,9 +105,17 @@ for each replayed fragment: detail = sample(categoryTexture, baseUv * categoryTiling) diffuseAlpha = base_subset_diffuse_alpha // 1 for opaque; the // translucency-fade multiplier - // for a fading subset - // (tmpmaterial.Diffuse.a in - // RenderMeshSubset) + // for a fading subset. + // tmpmaterial.Diffuse.a = 1f + // (0x0059cb99) is the + // burnedInStaticLights < 0 && + // *(render_device+0x7e4) == 0 + // branch in RenderMeshSubset; + // the other branch leaves + // diffuse FromVertex. Either + // way the opaque->1 / + // fading->opacity mapping + // still holds. // D3DPolyRender::SetSurface (0x0059c4d0) texture-stage setup: // stage 0 colour = MODULATE(TEXTURE, DIFFUSE) = base.rgb * diffuse.rgb @@ -176,6 +184,18 @@ change base coverage/blend/depth behavior; it avoids making the checkbox reorder the default transparent scene. The connected acceptance matrix must still exercise overlapping transparent building/EnvCell surfaces. +Also unmodelled: retail's stage-1 OUTPUT alpha — `MODULATE(TEXTURE, CURRENT)` +(`0x0059c549`) — which for a delayed-alpha subset becomes the framebuffer +blend weight the alpha queue composites that subset with. acdream instead +draws the base subset with its own alpha and a second, separately blended +draw weighted by `detail.a * diffuseAlpha` (the pipeline in +`VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail)`). For +opaque subsets this is identical (both reduce to the base's own alpha +gating nothing else downstream); on translucent building/EnvCell subsets it +is a bounded difference in how much the SUBSEQUENT alpha-queue compositing +sees, already covered by the existing AP-34 shared-alpha-queue divergence +row rather than a new one. + ## Darkening, not brightening The earlier version of this note read the two-pass fallback's `DEST_COLOR + diff --git a/src/AcDream.App/Rendering/RetailDetailTextureContract.cs b/src/AcDream.App/Rendering/RetailDetailTextureContract.cs index c647d5ab..36fc600a 100644 --- a/src/AcDream.App/Rendering/RetailDetailTextureContract.cs +++ b/src/AcDream.App/Rendering/RetailDetailTextureContract.cs @@ -77,4 +77,33 @@ internal static class RetailDetailTextureContract /// internal static bool IsNeutral(Vector4 detail, float opacity) => detail.W * opacity == 0f; + + /// + /// Review fix (post-05970306): retail's D3D fog stage runs AFTER the + /// texture-stage combine, applying to the FINAL pixel, not to + /// detail.rgb 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: lerp(mix(base,fog,f), mix(detail,fog,f), detail.a*opacity). + /// It is algebraically identical to retail's single-draw + /// fog-after-combine order, mix(Expected(base,detail,opacity), fog, + /// f) — see RetailDetailTextureContractTests for the identity + /// pinned numerically, and mesh_detail.frag's header comment for the + /// derivation. + /// + 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); + } } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.frag b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag index 7a0ec49b..380c0c22 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_detail.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag @@ -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); } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert index ad43ece2..9c8b8dad 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert @@ -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]; diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv index 95f1748a..caf4e76d 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv index 033db8b4..03ed2c55 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index ed1abf25..9e1b750d 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -231,12 +231,12 @@ "stages": [ { "stage": "vert", - "sourceSha256": "c955bdce56199ef2057dbaf0dc1d1f75ececac49047f57685b09b63117e921da", + "sourceSha256": "3b03a153a439aa54fcd0bcb575274c8e1da888b8ae85ee15fae020ccb1d3bf49", "compiled": true }, { "stage": "frag", - "sourceSha256": "c02dd48647352a87c183fe925a9590b70731c677dd942bcea71ac6ad63ff486d", + "sourceSha256": "e037fd28cf71453792c17522c97c4c7797193823ed78ca629a4331b632f177b1", "compiled": true } ] diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs index f61a27a6..1e22472b 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs @@ -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) { diff --git a/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs b/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs index 1e4bd303..33630fd8 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailDetailTextureContractTests.cs @@ -103,4 +103,46 @@ public sealed class RetailDetailTextureContractTests Assert.True(MathF.Abs(result.Y - detail.Y) < 1e-6f); Assert.True(MathF.Abs(result.Z - detail.Z) < 1e-6f); } + + [Fact] + public void FogAppliedPerDrawMatchesRetailsFogAfterCombineOrder() + { + // Review fix (post-05970306): retail fogs the pixel AFTER the + // texture-stage combine (mix(lerp(base,detail,a), fog, f)), but + // acdream draws the combine as two separate passes, each fogging its + // own colour before the fixed-function blend recombines them + // (lerp(mix(base,fog,f), mix(detail,fog,f), a)). This pins that the + // two orders are the same pixel for arbitrary inputs — see + // ExpectedFogged's doc comment and mesh_detail.frag's header comment + // for the algebra. + var random = new Random(0x226226); + for (int i = 0; i < 200; i++) + { + var baseColour = NextColour(random); + var detail = new Vector4(NextColour(random), random.NextSingle()); + float opacity = random.NextSingle(); + var fog = NextColour(random); + float fogFactor = random.NextSingle(); + + Vector3 twoDrawOrder = RetailDetailTextureContract.ExpectedFogged( + baseColour, detail, opacity, fog, fogFactor); + Vector3 retailOrder = Vector3.Lerp( + RetailDetailTextureContract.Expected(baseColour, detail, opacity), + fog, + fogFactor); + + Assert.True( + MathF.Abs(twoDrawOrder.X - retailOrder.X) < 1e-6f, + $"R: two-draw {twoDrawOrder.X} vs retail {retailOrder.X} (sample {i})"); + Assert.True( + MathF.Abs(twoDrawOrder.Y - retailOrder.Y) < 1e-6f, + $"G: two-draw {twoDrawOrder.Y} vs retail {retailOrder.Y} (sample {i})"); + Assert.True( + MathF.Abs(twoDrawOrder.Z - retailOrder.Z) < 1e-6f, + $"B: two-draw {twoDrawOrder.Z} vs retail {retailOrder.Z} (sample {i})"); + } + } + + private static Vector3 NextColour(Random random) => + new(random.NextSingle(), random.NextSingle(), random.NextSingle()); } diff --git a/tests/AcDream.App.Tests/Rendering/TerrainAtlasDetailTextureTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainAtlasDetailTextureTests.cs new file mode 100644 index 00000000..ca048f71 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/TerrainAtlasDetailTextureTests.cs @@ -0,0 +1,173 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Wb; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib.IO; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Campaign VM VM1 review fix: pins the two facts the #226 note relies on +/// for retail's mip-driven attenuation to actually be true — TerrainAtlas +/// .TryCreateDetailTexture (~TerrainAtlas.cs:488-556) must upload a FULL +/// mip chain, and it must be sampled with the repeat/linear world sampler, +/// or the "attenuation is the sampler's linear mip chain" claim in +/// mesh_detail.vert's header comment and the VM1 commit is unverified. +/// Drives the private method directly (reflection, same pattern as +/// EnvCellRendererTests' private-method tests) against a synthetic +/// PFID_A8R8G8B8 RenderSurface, so no installed DAT is required — this lane +/// stays hermetic. +/// +public sealed class TerrainAtlasDetailTextureTests +{ + private const uint SurfaceTextureId = 0x05001787u; + private const uint RenderSurfaceId = 0x06006D58u; + + [Fact] + public void DetailTexture_UploadsFullMipChainAndUsesRepeatLinearSampler() + { + const int width = 4; + const int height = 4; + + using var device = new RecordingGpuDevice(); + device.Clear(); + + var dats = new FakeDetailTextureDats(); + dats.Register(new SurfaceTexture + { + Textures = new List> { RenderSurfaceId }, + }, SurfaceTextureId); + dats.Register(new RenderSurface + { + Width = width, + Height = height, + Format = PixelFormat.PFID_A8R8G8B8, + SourceData = new byte[width * height * 4], + }, RenderSurfaceId); + + var terrain = new TMTerrainDesc + { + TerrainTex = new TerrainTex + { + DetailTextureId = SurfaceTextureId, + DetailTexTiling = 4u, + }, + }; + + IGpuSampler sampler = device.CreateSampler(GpuSamplerDescription.WorldRepeat); + + MethodInfo method = typeof(TerrainAtlas).GetMethod( + "TryCreateDetailTexture", + BindingFlags.NonPublic | BindingFlags.Static)!; + object? result = method.Invoke( + null, + new object[] { device, dats, sampler, terrain, "building" }); + + Assert.NotNull(result); + + // Full mip chain: TerrainAtlas.cs sizes MipLevelCount from + // RhiWorldTextureArray.MipLevelsFor(decoded.Width, decoded.Height) and + // then calls GenerateMipChain() — not a single-level upload. + RecordingGpuTexture texture = Assert.Single(device.CreatedTextures); + Assert.Equal(width, texture.Width); + Assert.Equal(height, texture.Height); + int expectedMipLevels = RhiWorldTextureArray.MipLevelsFor(width, height); + Assert.True(expectedMipLevels > 1, "the test fixture must exercise a real mip chain, not a 1x1 edge case"); + Assert.Equal(expectedMipLevels, texture.MipLevelCount); + Assert.True(texture.MipChainGenerated); + + // Repeat/linear sampler: the exact sampler GpuBindingModel world + // draws use, not WorldClamp (the alpha atlas' sampler) or any + // point-filtered UI sampler. + GpuRecordedTextureRegistration registration = Assert.Single( + device.Calls.OfType()); + Assert.Equal(GpuSamplerDescription.WorldRepeat, registration.Sampler); + Assert.Equal(GpuFilter.Linear, registration.Sampler.MinFilter); + Assert.Equal(GpuFilter.Linear, registration.Sampler.MagFilter); + Assert.Equal(GpuMipFilter.Linear, registration.Sampler.MipFilter); + Assert.Equal(GpuAddressMode.Repeat, registration.Sampler.AddressU); + Assert.Equal(GpuAddressMode.Repeat, registration.Sampler.AddressV); + } + + /// + /// Minimal synthetic — same shape as the + /// NoopDatReaderWriter pattern already used for hermetic tests + /// (LiveEntityNetworkOnPositionCollapseMatrixTests), except Get + /// resolves from an explicit id->object map instead of always missing. + /// Every other member is unreachable by TryCreateDetailTexture and throws + /// if that assumption ever changes. + /// + private sealed class FakeDetailTextureDats : IDatReaderWriter + { + private readonly Dictionary _objects = new(); + + public void Register(T obj, uint id) where T : IDBObj => _objects[id] = obj; + + public string SourceDirectory => string.Empty; + public IDatDatabase Portal => throw new NotSupportedException(); + public IDatDatabase Cell => throw new NotSupportedException(); + public ReadOnlyDictionary CellRegions { get; } = + new(new Dictionary()); + public IDatDatabase HighRes => throw new NotSupportedException(); + public IDatDatabase Language => throw new NotSupportedException(); + public IDatDatabase Local => throw new NotSupportedException(); + public ReadOnlyDictionary RegionFileMap { get; } = + new(new Dictionary()); + public int PortalIteration => 0; + public int CellIteration => 0; + public int HighResIteration => 0; + public int LanguageIteration => 0; + + public bool TryGetFileBytes( + uint regionId, + uint fileId, + ref byte[] bytes, + out int bytesRead) + { + bytesRead = 0; + return false; + } + + public IEnumerable GetAllIdsOfType() where T : IDBObj => + Array.Empty(); + + public IEnumerable ResolveId(uint id) => + Array.Empty(); + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public bool TrySave(uint regionId, T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + [return: MaybeNull] + public T Get(uint fileId) where T : IDBObj => + _objects.TryGetValue(fileId, out IDBObj? obj) && obj is T typed ? typed : default; + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj + { + if (_objects.TryGetValue(fileId, out IDBObj? obj) && obj is T typed) + { + value = typed; + return true; + } + value = default; + return false; + } + + public void Dispose() + { + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs index e6ae4cbc..4ae2c907 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -116,8 +116,11 @@ public class EnvCellRendererTests /// pass, mesh_modern's shell pipeline and (when enabled) mesh_detail's /// replay alike. /// - [Fact] - public void SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass() + [Theory] + [InlineData(WbRenderPass.Opaque)] + [InlineData(WbRenderPass.Transparent)] + public void SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass( + WbRenderPass renderPass) { const int instanceCount = 5; @@ -144,7 +147,13 @@ public class EnvCellRendererTests // Seed one real draw command, exactly what // RenderModernMDIInternal would have built from a live landblock, // so the test can assert an actual ordering against a real draw - // rather than a vacuous "no draw happened" pass. + // rather than a vacuous "no draw happened" pass. Same command for + // both passes: with _buildingDetailEnabled left at its default + // (false), the Transparent branch's own detail-interleave logic + // (SubmitRhi's "detailEnabled" block) never fires, so it draws + // through the identical MultiDrawIndexedIndirect call the Opaque + // pass does — only the pipeline rebound inside the per-range loop + // differs, which is not what this test is pinning. Type rendererType = typeof(EnvCellRenderer); FieldInfo commandsField = rendererType.GetField( "_commands", BindingFlags.NonPublic | BindingFlags.Instance)!; @@ -184,7 +193,7 @@ public class EnvCellRendererTests "SubmitRhi", BindingFlags.NonPublic | BindingFlags.Instance)!; submitRhi.Invoke( renderer, - new object[] { allInstances, WbRenderPass.Opaque, 1, instanceCount }); + new object[] { allInstances, renderPass, 1, instanceCount }); IReadOnlyList calls = device.Calls; int alphaBindIndex = -1;