fix(render): detail overlay is fogged after the combine like retail; VM1 review fixes
Opus dual-lens review of05970306+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:
parent
388457a735
commit
ae6513126e
11 changed files with 343 additions and 14 deletions
|
|
@ -105,9 +105,17 @@ for each replayed fragment:
|
||||||
detail = sample(categoryTexture, baseUv * categoryTiling)
|
detail = sample(categoryTexture, baseUv * categoryTiling)
|
||||||
diffuseAlpha = base_subset_diffuse_alpha // 1 for opaque; the
|
diffuseAlpha = base_subset_diffuse_alpha // 1 for opaque; the
|
||||||
// translucency-fade multiplier
|
// translucency-fade multiplier
|
||||||
// for a fading subset
|
// for a fading subset.
|
||||||
// (tmpmaterial.Diffuse.a in
|
// tmpmaterial.Diffuse.a = 1f
|
||||||
// RenderMeshSubset)
|
// (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:
|
// D3DPolyRender::SetSurface (0x0059c4d0) texture-stage setup:
|
||||||
// stage 0 colour = MODULATE(TEXTURE, DIFFUSE) = base.rgb * diffuse.rgb
|
// 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
|
reorder the default transparent scene. The connected acceptance matrix must
|
||||||
still exercise overlapping transparent building/EnvCell surfaces.
|
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
|
## Darkening, not brightening
|
||||||
|
|
||||||
The earlier version of this note read the two-pass fallback's `DEST_COLOR +
|
The earlier version of this note read the two-pass fallback's `DEST_COLOR +
|
||||||
|
|
|
||||||
|
|
@ -77,4 +77,33 @@ internal static class RetailDetailTextureContract
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static bool IsNeutral(Vector4 detail, float opacity) =>
|
internal static bool IsNeutral(Vector4 detail, float opacity) =>
|
||||||
detail.W * opacity == 0f;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
in vec2 vBaseUv;
|
in vec2 vBaseUv;
|
||||||
in vec2 vDetailUv;
|
in vec2 vDetailUv;
|
||||||
in float vDetailOpacity;
|
in float vDetailOpacity;
|
||||||
|
in vec3 vWorldPos;
|
||||||
in flat uint vBaseTextureIndex;
|
in flat uint vBaseTextureIndex;
|
||||||
in flat uint vBaseTextureLayer;
|
in flat uint vBaseTextureLayer;
|
||||||
in flat uint vBatchFlags;
|
in flat uint vBatchFlags;
|
||||||
|
|
@ -11,6 +12,42 @@ in flat uint vDetailCategory;
|
||||||
|
|
||||||
uniform uint uTextureIndexA; // category detail texture, layer 0
|
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;
|
out vec4 FragColor;
|
||||||
|
|
||||||
// VM2 (2026-08-22, live cdb read on the PDB-paired retail client, GUID
|
// 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
|
// that combined alpha and lets the fixed-function blend unit do the
|
||||||
// base*(1-a) + detail*a lerp. See
|
// base*(1-a) + detail*a lerp. See
|
||||||
// docs/research/2026-08-22-vm2-retail-detail-path-cdb.md.
|
// 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() {
|
void main() {
|
||||||
// Object command replays may contain ordinary instances; only building
|
// Object command replays may contain ordinary instances; only building
|
||||||
|
|
@ -56,5 +107,5 @@ void main() {
|
||||||
uTextureIndexA,
|
uTextureIndexA,
|
||||||
vec3(vDetailUv, 0.0));
|
vec3(vDetailUv, 0.0));
|
||||||
|
|
||||||
FragColor = vec4(detail.rgb, detail.a * vDetailOpacity);
|
FragColor = vec4(applyFog(detail.rgb, vWorldPos), detail.a * vDetailOpacity);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ uniform float uParamB; // 1 = require building instance, 0 = EnvCell category
|
||||||
out vec2 vBaseUv;
|
out vec2 vBaseUv;
|
||||||
out vec2 vDetailUv;
|
out vec2 vDetailUv;
|
||||||
out float vDetailOpacity;
|
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 vBaseTextureIndex;
|
||||||
out flat uint vBaseTextureLayer;
|
out flat uint vBaseTextureLayer;
|
||||||
out flat uint vBatchFlags;
|
out flat uint vBatchFlags;
|
||||||
|
|
@ -80,6 +82,7 @@ void main() {
|
||||||
int instanceIndex = transformIndex - int(uTextureIndexB);
|
int instanceIndex = transformIndex - int(uTextureIndexB);
|
||||||
vec4 worldPos = Instances[transformIndex].transform * vec4(aPosition, 1.0);
|
vec4 worldPos = Instances[transformIndex].transform * vec4(aPosition, 1.0);
|
||||||
gl_Position = uViewProjection * worldPos;
|
gl_Position = uViewProjection * worldPos;
|
||||||
|
vWorldPos = worldPos.xyz;
|
||||||
|
|
||||||
uint slot = instanceClipSlot[instanceIndex];
|
uint slot = instanceClipSlot[instanceIndex];
|
||||||
CellClip clip = clipRegions[slot];
|
CellClip clip = clipRegions[slot];
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -231,12 +231,12 @@
|
||||||
"stages": [
|
"stages": [
|
||||||
{
|
{
|
||||||
"stage": "vert",
|
"stage": "vert",
|
||||||
"sourceSha256": "c955bdce56199ef2057dbaf0dc1d1f75ececac49047f57685b09b63117e921da",
|
"sourceSha256": "3b03a153a439aa54fcd0bcb575274c8e1da888b8ae85ee15fae020ccb1d3bf49",
|
||||||
"compiled": true
|
"compiled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"stage": "frag",
|
"stage": "frag",
|
||||||
"sourceSha256": "c02dd48647352a87c183fe925a9590b70731c677dd942bcea71ac6ad63ff486d",
|
"sourceSha256": "e037fd28cf71453792c17522c97c4c7797193823ed78ca629a4331b632f177b1",
|
||||||
"compiled": true
|
"compiled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -343,10 +343,12 @@ public sealed unsafe partial class EnvCellRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retail DrawEnvCell category (2). Replay the already-filtered opaque
|
// Retail DrawEnvCell category (2). Replay the already-filtered opaque
|
||||||
// shell commands, including ClipMap built-mesh subsets, and apply the
|
// shell commands, including ClipMap built-mesh subsets. No distance
|
||||||
// 10-50 m positive-view-depth fade. The existing
|
// fade (VM1/VM2): retail's noFadeDetail gates get_alpha_for_z to the
|
||||||
// "Building Detail Textures" option gates both this and buildings,
|
// immediate-polygon path only, which built meshes never reach;
|
||||||
// matching LScape::ChangeRegion.
|
// 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
|
if (renderPass == WbRenderPass.Opaque
|
||||||
&& detailEnabled)
|
&& detailEnabled)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -103,4 +103,46 @@ public sealed class RetailDetailTextureContractTests
|
||||||
Assert.True(MathF.Abs(result.Y - detail.Y) < 1e-6f);
|
Assert.True(MathF.Abs(result.Y - detail.Y) < 1e-6f);
|
||||||
Assert.True(MathF.Abs(result.Z - detail.Z) < 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());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign VM VM1 review fix: pins the two facts the #226 note relies on
|
||||||
|
/// for retail's mip-driven attenuation to actually be true — <c>TerrainAtlas
|
||||||
|
/// .TryCreateDetailTexture</c> (~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.
|
||||||
|
/// </summary>
|
||||||
|
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<QualifiedDataId<RenderSurface>> { 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<GpuRecordedTextureRegistration>());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal synthetic <see cref="IDatReaderWriter"/> — same shape as the
|
||||||
|
/// NoopDatReaderWriter pattern already used for hermetic tests
|
||||||
|
/// (LiveEntityNetworkOnPositionCollapseMatrixTests), except <c>Get</c>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeDetailTextureDats : IDatReaderWriter
|
||||||
|
{
|
||||||
|
private readonly Dictionary<uint, IDBObj> _objects = new();
|
||||||
|
|
||||||
|
public void Register<T>(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<uint, IDatDatabase> CellRegions { get; } =
|
||||||
|
new(new Dictionary<uint, IDatDatabase>());
|
||||||
|
public IDatDatabase HighRes => throw new NotSupportedException();
|
||||||
|
public IDatDatabase Language => throw new NotSupportedException();
|
||||||
|
public IDatDatabase Local => throw new NotSupportedException();
|
||||||
|
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
||||||
|
new(new Dictionary<uint, uint>());
|
||||||
|
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<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
||||||
|
Array.Empty<uint>();
|
||||||
|
|
||||||
|
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
||||||
|
Array.Empty<IDatReaderWriter.IdResolution>();
|
||||||
|
|
||||||
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
[return: MaybeNull]
|
||||||
|
public T Get<T>(uint fileId) where T : IDBObj =>
|
||||||
|
_objects.TryGetValue(fileId, out IDBObj? obj) && obj is T typed ? typed : default;
|
||||||
|
|
||||||
|
public bool TryGet<T>(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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -116,8 +116,11 @@ public class EnvCellRendererTests
|
||||||
/// pass, mesh_modern's shell pipeline and (when enabled) mesh_detail's
|
/// pass, mesh_modern's shell pipeline and (when enabled) mesh_detail's
|
||||||
/// replay alike.
|
/// replay alike.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Theory]
|
||||||
public void SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass()
|
[InlineData(WbRenderPass.Opaque)]
|
||||||
|
[InlineData(WbRenderPass.Transparent)]
|
||||||
|
public void SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass(
|
||||||
|
WbRenderPass renderPass)
|
||||||
{
|
{
|
||||||
const int instanceCount = 5;
|
const int instanceCount = 5;
|
||||||
|
|
||||||
|
|
@ -144,7 +147,13 @@ public class EnvCellRendererTests
|
||||||
// Seed one real draw command, exactly what
|
// Seed one real draw command, exactly what
|
||||||
// RenderModernMDIInternal would have built from a live landblock,
|
// RenderModernMDIInternal would have built from a live landblock,
|
||||||
// so the test can assert an actual ordering against a real draw
|
// 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);
|
Type rendererType = typeof(EnvCellRenderer);
|
||||||
FieldInfo commandsField = rendererType.GetField(
|
FieldInfo commandsField = rendererType.GetField(
|
||||||
"_commands", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
"_commands", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||||
|
|
@ -184,7 +193,7 @@ public class EnvCellRendererTests
|
||||||
"SubmitRhi", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
"SubmitRhi", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||||
submitRhi.Invoke(
|
submitRhi.Invoke(
|
||||||
renderer,
|
renderer,
|
||||||
new object[] { allInstances, WbRenderPass.Opaque, 1, instanceCount });
|
new object[] { allInstances, renderPass, 1, instanceCount });
|
||||||
|
|
||||||
IReadOnlyList<GpuRecordedCall> calls = device.Calls;
|
IReadOnlyList<GpuRecordedCall> calls = device.Calls;
|
||||||
int alphaBindIndex = -1;
|
int alphaBindIndex = -1;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue