feat(rendering): port retail one-pass detail material

Replace the building and EnvCell detail replay with retail's exact single-pass stage result, including authored surface opacity, squared detail alpha, final-alpha clipping, and the original subset pipeline/order. Arm the ordered walk command in place to close #471, delete the replay pipelines/shaders, and advance prepared content to recipe 9.

Mutation witnesses (each restored before commit):
- X=a*qA: RetailDetailTextureContractTests.BothShaderFamiliesUseTheSharedOnePassSourceAndDebugPrecedesDetailSample line 174, missing materialAlpha * detail.a * detail.a.
- X*=base alpha: same test line 175, forbidden baseTexel.a found.
- CLIP against base alpha: EnvCellAlphaDrawSourceTests.ClipShaders_UseGreaterEqualForThePerRangeReference line 260, final-X conditional missing.
- second detail draw: EnvCellAlphaDrawSourceTests.DetailOn_EveryEnvCellFamilyDrawsOnceInPlaceWithAuthoredOpacity line 105, Assert.Single saw 2 MDI calls.
- straight-alpha substitution: WalkStaticStreamPopulatorTests.ImmediateBuildingDetail_RetainsOriginalFramebufferFamily line 1244, Additive first failed (only wb-mesh-alpha-1x recorded; InvAlpha also failed).
- omit ordered arm: OrderPreservingSubmitterTests.PrepareThenDraw_OrdinaryBuildingClipBuildingOrdinary_ArmsOnePassInPlace line 305, expected (77,3.5), got (0,0).
- omit atmospheric combine: RetailDetailTextureContractTests.BothShaderFamiliesUseTheSharedOnePassSourceAndDebugPrecedesDetailSample line 173, atmospheric shared include missing.
- drop serialized opacity: ObjectMeshDataSerializerTests.SurfaceOpacity_RoundTripsBitExactlyAndDeterministically line 288, first reported 0.5 bits 1056964608 vs 1065353216.
- stale detail arm: ordered adjacency test line 307, expected following ordinary (0,0), got (77,3.5).
- per-frame surface map: EnvCellAlphaDrawSourceTests.ProductionWholeLeaf_WarmedScanSubmitRhiAndFilteredReplayDoNotAllocate line 178, expected 0 B, got 147456 B.

Verification before commit: shader compiler 23/23; focused App 213/213; Content 75/75; Core Wb 10/10; launcher migration 6/6; Release solution build 0 warnings / 0 errors; git diff --check clean.
This commit is contained in:
Erik 2026-09-04 23:45:26 +02:00
parent 13dbb79eeb
commit 75664805f8
61 changed files with 1003 additions and 1016 deletions

File diff suppressed because one or more lines are too long

View file

@ -143,17 +143,6 @@ internal enum GpuBlendMode
/// `ParticleRenderer` needs it too (slice V4e). /// `ParticleRenderer` needs it too (slice V4e).
/// </summary> /// </summary>
InverseAlpha, InverseAlpha,
/// <summary>
/// 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,
} }
internal enum GpuCompareOp internal enum GpuCompareOp

View file

@ -56,7 +56,7 @@ internal struct GpuPushConstants
public uint TextureIndexA; public uint TextureIndexA;
/// <summary>GLSL <c>uTextureIndexB</c>. Secondary per-pass slot; terrain /// <summary>GLSL <c>uTextureIndexB</c>. Secondary per-pass slot; terrain
/// uses it for the alpha-mask array, while shared-pose world/detail passes /// uses it for the alpha-mask array, while shared-pose world passes
/// carry the absolute transform-prefix instance count.</summary> /// carry the absolute transform-prefix instance count.</summary>
public uint TextureIndexB; public uint TextureIndexB;

View file

@ -87,7 +87,7 @@ internal sealed record VulkanDeviceFeatureSupport
/// <summary> /// <summary>
/// #459: the shader compiler targets Vulkan 1.3 / SPIR-V 1.6, where glslang /// #459: the shader compiler targets Vulkan 1.3 / SPIR-V 1.6, where glslang
/// lowers every fragment <c>discard</c> to <c>OpDemoteToHelperInvocation</c> /// lowers every fragment <c>discard</c> to <c>OpDemoteToHelperInvocation</c>
/// (five committed modules declare the capability — sky, mesh_detail, /// (five committed modules declare the capability — sky, mesh_modern,
/// mesh_atmospheric and both directional-shadow cutout fragments). A module /// mesh_atmospheric and both directional-shadow cutout fragments). A module
/// that declares the capability without the feature enabled is invalid /// that declares the capability without the feature enabled is invalid
/// usage (<c>VUID-VkShaderModuleCreateInfo-pCode-08740</c>), so the feature /// usage (<c>VUID-VkShaderModuleCreateInfo-pCode-08740</c>), so the feature

View file

@ -178,12 +178,6 @@ internal static class VulkanViewportMapping
GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One), GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One),
// Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend. // Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend.
GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
// 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), GpuBlendMode.None => (BlendFactor.One, BlendFactor.Zero),
_ => throw new ArgumentOutOfRangeException(nameof(blend), blend, "Unknown blend mode."), _ => throw new ArgumentOutOfRangeException(nameof(blend), blend, "Unknown blend mode."),
}; };

View file

@ -1,11 +1,10 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
/// <summary> /// <summary>
/// Testable CPU statement of retail's detail-pass gate and pixel math. The /// Testable CPU statement of retail's detail gate and one-pass pixel math. The
/// production pixels are produced by <c>mesh_detail</c>; keeping these facts /// production pixels are produced by both world-mesh fragment families; keeping these facts
/// in one small contract makes the setting and the combine independently /// in one small contract makes the setting and the combine independently
/// assertable without a GPU. /// assertable without a GPU.
/// ///
@ -17,15 +16,12 @@ namespace AcDream.App.Rendering;
/// <c>D3DPolyRender::RenderMeshSubset</c> (0x0059ca10) never falls back to /// <c>D3DPolyRender::RenderMeshSubset</c> (0x0059ca10) never falls back to
/// the two-pass framebuffer blend the earlier #226 port reproduced; it takes /// the two-pass framebuffer blend the earlier #226 port reproduced; it takes
/// the single-pass texture-stage combine set up in /// the single-pass texture-stage combine set up in
/// <c>D3DPolyRender::SetSurface</c> (0x0059c4d0): /// <c>D3DPolyRender::SetSurface</c> (0x0059c4d0). With base RGB <c>B</c>,
/// <c>lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)</c> — a blend /// diffuse RGB <c>D</c>, active material alpha <c>a</c>, and detail texel
/// TOWARD the detail colour by <c>detail.a * diffuse.a</c>, not the /// <c>Q=(q,qA)</c>, that stage program emits
/// fallback's <c>dest * (detail.rgb + 1 - detail.a)</c>. Built meshes light /// <c>C=q*(a*qA)+(B*D)*(1-a*qA)</c> and <c>X=a*qA*qA</c>. Base texture alpha
/// with <c>tmpmaterial.Diffuse.a = 1</c> for opaque subsets /// is deliberately excluded. This is not the fallback's framebuffer replay
/// (<c>RenderMeshSubset</c>), so on the live Dereth category texture (mean /// result <c>dest * (detail.rgb + 1 - detail.a)</c>.</para>
/// 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 /// <para>There is no distance fade on this path. Retail's
/// <c>ACRender::get_alpha_for_z</c> (0x006b6230) is only evaluated in /// <c>ACRender::get_alpha_for_z</c> (0x006b6230) is only evaluated in
@ -44,66 +40,69 @@ internal static class RetailDetailTextureContract
settingEnabled && binding.IsAvailable; settingEnabled && binding.IsAvailable;
/// <summary> /// <summary>
/// Opaque detail must compare equal against the depth written by its exact /// Retail's exact pre-fog stage result. Base alpha is deliberately absent.
/// 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> /// </summary>
internal static GpuCompareOp DetailDepthCompare(bool transparent) => internal static Vector4 Combine(
transparent ? GpuCompareOp.LessOrEqual : GpuCompareOp.Equal; Vector4 baseTexel,
Vector3 diffuse,
/// <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, Vector4 detail,
float opacity, float authoredOpacity,
Vector3 fog, float liveOpacity)
float fogFactor)
{ {
Vector3 foggedBase = Vector3.Lerp(baseColour, fog, fogFactor); float alpha = authoredOpacity * liveOpacity;
Vector3 foggedDetail = Vector3.Lerp( float weight = alpha * detail.W;
new Vector3(detail.X, detail.Y, detail.Z), Vector3 colour = new(detail.X, detail.Y, detail.Z);
fog, colour *= weight;
fogFactor); colour += new Vector3(baseTexel.X, baseTexel.Y, baseTexel.Z)
return Vector3.Lerp(foggedBase, foggedDetail, detail.W * opacity); * diffuse * (1f - weight);
float outputAlpha = alpha * detail.W * detail.W;
return new Vector4(colour, outputAlpha);
}
/// <summary>
/// Applies the subset's post-material fog to RGB. Retail fog never changes
/// the detail-active final alpha.
/// </summary>
internal static Vector4 ApplyFog(Vector4 material, Vector3 fog, float fogFactor) =>
new(Vector3.Lerp(new Vector3(material.X, material.Y, material.Z), fog, fogFactor), material.W);
/// <summary>
/// Mirrors D3D's GREATER_EQUAL alpha test: equality survives.
/// </summary>
internal static bool SurvivesClip(float outputAlpha, float reference) =>
outputAlpha >= reference;
/// <summary>
/// The retained framebuffer-family equations applied to retail's one-pass
/// source fragment.
/// </summary>
internal enum FramebufferFamily
{
Opaque,
Alpha,
AlphaAdditive,
Additive,
InverseAlpha,
InverseAlphaAdditive,
Clip,
}
internal static Vector4 Composite(
Vector4 source,
Vector4 destination,
FramebufferFamily family)
{
float x = source.W;
return family switch
{
FramebufferFamily.Opaque => source,
FramebufferFamily.Alpha => source * x + destination * (1f - x),
FramebufferFamily.AlphaAdditive => source * x + destination,
FramebufferFamily.Additive => source + destination,
FramebufferFamily.InverseAlpha => source * (1f - x) + destination * x,
FramebufferFamily.InverseAlphaAdditive => source * (1f - x) + destination,
FramebufferFamily.Clip => source + destination * new Vector4(1f - x),
_ => throw new ArgumentOutOfRangeException(nameof(family)),
};
} }
} }

View file

@ -14,8 +14,12 @@ in flat uint vTextureLayer;
in flat float vOpacityMultiplier; // #188 in flat float vOpacityMultiplier; // #188
in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse
in flat uint vReceivesDirectionalShadow; in flat uint vReceivesDirectionalShadow;
in flat float vSurfaceOpacity;
in flat uint vBatchFlags;
in flat uint vDetailCategory;
#include "directional_shadow_receiver.glsl" #include "directional_shadow_receiver.glsl"
#include "retail_detail_material.glsl"
// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test): // uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
// 0 = opaque pass — discard fragments with alpha < 0.95 // 0 = opaque pass — discard fragments with alpha < 0.95
@ -26,6 +30,9 @@ in flat uint vReceivesDirectionalShadow;
// transparent overdraw cost otherwise) // transparent overdraw cost otherwise)
uniform int uRenderPass; uniform int uRenderPass;
uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here
uniform uint uTextureIndexA;
uniform float uParamA;
uniform float uParamB;
// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1. // SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1.
struct Light { struct Light {
@ -61,35 +68,14 @@ vec3 applyFog(vec3 lit, vec3 worldPos) {
out vec4 FragColor; out vec4 FragColor;
bool isRetailClipReference(float value) {
return abs(value - (100.0 / 255.0)) < 0.000001
|| abs(value - (200.0 / 255.0)) < 0.000001;
}
void main() { void main() {
vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer))); vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer)));
float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05;
// Two-pass alpha-test (N.5 Decision 2).
// A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE
// derives the MSAA sample mask from it — ClipMap foliage edges become smooth.
// Discard only fully-transparent (α < 0.05); the GPU handles coverage masking.
if (uRenderPass == 0) {
if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C)
} else {
// Transparent pass.
//
// Phase Post-A.5 (ISSUE #52, 2026-05-10): do NOT discard α≥0.95 here.
// Native AC transparent-flagged surfaces routinely include
// effectively-opaque pixels — e.g. the Holtburg lifestone crystal core
// (surface 0x080011DE) which the spawn manifest classifies as
// transparent (batch.IsTransparent=True) but whose decoded texture
// alpha lands ≥0.95 across the visible surface. Those pixels still
// compose correctly under (SrcAlpha, 1-SrcAlpha) alpha-blending, so
// discarding them here threw away the whole crystal. The original
// N.5 §2 rationale (high-α fragments belong in the opaque pass) does
// not apply when the SURFACE is dat-flagged transparent — those
// pixels can't reach the opaque pass at all.
//
// Keep the α<0.05 short-circuit as a fragment-cost optimization
// (skip fully-empty pixels — saves blend bandwidth on alpha-keyed
// sprites with large transparent margins).
if (color.a < 0.05) discard;
}
// Only the authored outdoor directional term is shadowed. Ambient, local // Only the authored outdoor directional term is shadowed. Ambient, local
// lights and material selection pulses remain exactly on the retail path. // lights and material selection pulses remain exactly on the retail path.
@ -110,6 +96,7 @@ void main() {
// ignored). Stripes visible HERE = a vertex-lighting artifact; absent = // ignored). Stripes visible HERE = a vertex-lighting artifact; absent =
// the pattern comes from texture/per-pixel machinery. Throwaway diagnostic. // the pattern comes from texture/per-pixel machinery. Throwaway diagnostic.
if (uLightDebug == 3) { if (uLightDebug == 3) {
if (color.a < alphaCutoff) discard;
FragColor = vec4(min(lit, vec3(1.0)), 1.0); FragColor = vec4(min(lit, vec3(1.0)), 1.0);
return; return;
} }
@ -120,11 +107,30 @@ void main() {
// Retail clamp per-channel to 1.0 (r13 §13.1). // Retail clamp per-channel to 1.0 (r13 §13.1).
lit = min(lit, vec3(1.0)); lit = min(lit, vec3(1.0));
vec3 rgb = color.rgb * lit; bool detailActive = uParamA != 0.0
&& vDetailCategory != 0u
&& (vBatchFlags & 1u) != 0u;
vec3 rgb;
float alpha;
if (detailActive) {
vec4 detail = ACDREAM_SAMPLE_ARRAY(
uTextureIndexA,
vec3(vTexCoord * uParamA, 0.0));
RetailDetailMaterialResult material = acdreamRetailDetailMaterial(
color.rgb,
lit,
detail,
vSurfaceOpacity * vOpacityMultiplier);
rgb = material.rgb;
alpha = material.alpha;
} else {
rgb = color.rgb * lit;
alpha = color.a * vOpacityMultiplier;
}
if (detailActive ? alpha < alphaCutoff : color.a < alphaCutoff)
discard;
rgb = applyFog(rgb, vWorldPos); rgb = applyFog(rgb, vWorldPos);
// #188: multiply the FINAL alpha only — the discard thresholds above stay FragColor = vec4(rgb, alpha);
// keyed on the raw sampled color.a, so the last few frames of a fade
// (multiplier crossing under 0.05) still ramp smoothly toward zero rather
// than popping invisible early against the discard cutoff.
FragColor = vec4(rgb, color.a * vOpacityMultiplier);
} }

View file

@ -23,7 +23,7 @@ struct InstanceData {
// CPU writer's layout is unchanged (GpuBindingModel.GpuBatchDataStrideBytes). // CPU writer's layout is unchanged (GpuBindingModel.GpuBatchDataStrideBytes).
struct BatchData { struct BatchData {
uint textureIndex; // slot into the global texture table uint textureIndex; // slot into the global texture table
uint _pad; // keeps textureLayer/flags at offsets 8/12 float surfaceOpacity; // authored material alpha (not base texture alpha)
uint textureLayer; // layer in the shared WB or pooled composite array uint textureLayer; // layer in the shared WB or pooled composite array
uint flags; // reserved — N.5 dispatcher owns all blend state uint flags; // reserved — N.5 dispatcher owns all blend state
// (glBlendFunc per pass). If a future phase wants // (glBlendFunc per pass). If a future phase wants
@ -116,6 +116,10 @@ layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf {
vec2 instanceSelectionLighting[]; vec2 instanceSelectionLighting[];
}; };
layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
uint instanceDetailCategory[];
};
uniform mat4 uViewProjection; uniform mat4 uViewProjection;
// Absolute transform prefix in the shared shadow/world pose arena. Every // Absolute transform prefix in the shared shadow/world pose arena. Every
// parallel per-instance array remains local to this submission, so only the // parallel per-instance array remains local to this submission, so only the
@ -309,6 +313,9 @@ out flat uint vTextureLayer;
out flat float vOpacityMultiplier; // #188 out flat float vOpacityMultiplier; // #188
out flat vec2 vSelectionLighting; out flat vec2 vSelectionLighting;
out flat uint vReceivesDirectionalShadow; out flat uint vReceivesDirectionalShadow;
out flat float vSurfaceOpacity;
out flat uint vBatchFlags;
out flat uint vDetailCategory;
void main() { void main() {
int transformIndex = gl_BaseInstanceARB + gl_InstanceID; int transformIndex = gl_BaseInstanceARB + gl_InstanceID;
@ -358,4 +365,7 @@ void main() {
// could read its flags — Campaign VM VM6.) // could read its flags — Campaign VM VM6.)
vTextureIndex = b.textureIndex; vTextureIndex = b.textureIndex;
vTextureLayer = b.textureLayer; vTextureLayer = b.textureLayer;
vSurfaceOpacity = b.surfaceOpacity;
vBatchFlags = b.flags;
vDetailCategory = instanceDetailCategory[instanceIndex];
} }

View file

@ -1,121 +0,0 @@
#version 430 core
#extension GL_ARB_bindless_texture : require
in vec2 vBaseUv;
in vec2 vDetailUv;
in float vDetailOpacity;
in vec3 vWorldPos;
in flat uint vBaseTextureIndex;
in flat uint vBaseTextureLayer;
in flat uint vBatchFlags;
in flat uint vDetailCategory;
uniform uint uTextureIndexA; // category detail texture, layer 0
// S4-c2 F3: 100/255 or 200/255 for a pure ClipMap base/detail pair. Exact 1
// remains mesh_detail.vert's existing building-category sentinel and must use
// the ordinary empty-fragment cutoff. Zero is the ordinary EnvCell category.
uniform float uParamB;
// 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;
bool isRetailClipReference(float value) {
return abs(value - (100.0 / 255.0)) < 0.000001
|| abs(value - (200.0 / 255.0)) < 0.000001;
}
// 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.
//
// 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
// shells survive. Bit 0 means this command came through retail's built-mesh
// DrawMesh path. Unlike the land-polygon path, RenderMeshSubset receives
// curr_detail_surface for every built-mesh material subset.
if (vDetailCategory == 0u || (vBatchFlags & 1u) == 0u)
discard;
vec4 base = ACDREAM_SAMPLE_ARRAY(
vBaseTextureIndex,
vec3(vBaseUv, float(vBaseTextureLayer)));
float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05;
if (base.a < alphaCutoff)
discard;
vec4 detail = ACDREAM_SAMPLE_ARRAY(
uTextureIndexA,
vec3(vDetailUv, 0.0));
FragColor = vec4(applyFog(detail.rgb, vWorldPos), detail.a * vDetailOpacity);
}

View file

@ -1,104 +0,0 @@
#version 430 core
#extension GL_ARB_shader_draw_parameters : require
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
struct InstanceData {
mat4 transform;
};
struct BatchData {
uint textureIndex;
uint _pad;
uint textureLayer;
uint flags;
};
layout(std430, binding = 0) readonly buffer InstanceBuffer {
InstanceData Instances[];
};
layout(std430, binding = 1) readonly buffer BatchBuffer {
BatchData Batches[];
};
// S3 review fix round 1 (F5): the per-cell screen-space gl_ClipDistance gate
// (Phase U.3's binding=2 CellClip region SSBO) is deleted — the CPU-side
// routing that could ever select a non-zero slot for an instance
// (WbDrawDispatcher's per-frame clip-routing arming call) had ZERO production callers, so every
// instance has always mapped to slot 0 (no-clip) in every shipped build; a
// shader-side clip test against a table that only ever holds the reserved
// no-clip slot clips nothing. binding=3 — PER-RENDERER per-instance slot
// index, parallel to the binding=0 instance buffer and indexed by the
// IDENTICAL per-instance index — stays declared (the CPU side still writes
// it, always 0) but is no longer read here; the instance-buffer layout it
// occupies is S5's to revisit.
layout(std430, binding = 3) readonly buffer ClipSlotBuf {
uint instanceClipSlot[];
};
// Exact uParamB=1 selects this per-instance building/object category. EnvCell
// zero and its pure-ClipMap 100/255 or 200/255 references retain the bound
// environment category; the fragment shader consumes those CLIP references.
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[];
};
uniform mat4 uViewProjection;
uniform int uDrawIDOffset;
uniform uint uTextureIndexB; // absolute transform prefix in the shared pose arena
uniform float uParamA; // detail UV tiling
// Exact 1 remains the existing "require building instance" sentinel. S4-c2
// also passes 100/255 or 200/255 for an EnvCell ClipMap alpha reference; both
// must retain the EnvCell category rather than taking the building branch.
uniform float uParamB;
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;
out flat uint vDetailCategory;
void main() {
int transformIndex = gl_BaseInstanceARB + gl_InstanceID;
int instanceIndex = transformIndex - int(uTextureIndexB);
vec4 worldPos = Instances[transformIndex].transform * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos;
vWorldPos = worldPos.xyz;
// 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;
BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB];
vBaseTextureIndex = batch.textureIndex;
vBaseTextureLayer = batch.textureLayer;
vBatchFlags = batch.flags;
vDetailCategory = uParamB == 1.0
? instanceDetailCategory[instanceIndex]
: 1u;
}

View file

@ -12,6 +12,11 @@ in flat uint vTextureIndex;
in flat uint vTextureLayer; in flat uint vTextureLayer;
in flat float vOpacityMultiplier; // #188 in flat float vOpacityMultiplier; // #188
in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse
in flat float vSurfaceOpacity;
in flat uint vBatchFlags;
in flat uint vDetailCategory;
#include "retail_detail_material.glsl"
// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test): // uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
// 0 = opaque pass — discard fragments with alpha < 0.95 // 0 = opaque pass — discard fragments with alpha < 0.95
@ -26,6 +31,8 @@ uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3
// recognized, supplied per MDI range from TextureKey.PaletteId (100/255 // recognized, supplied per MDI range from TextureKey.PaletteId (100/255
// paletted, 200/255 DDS/non-paletted). Every other value keeps 0.05. // paletted, 200/255 DDS/non-paletted). Every other value keeps 0.05.
uniform float uParamB; uniform float uParamB;
uniform uint uTextureIndexA;
uniform float uParamA;
// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1. // SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1.
struct Light { struct Light {
@ -70,35 +77,6 @@ void main() {
vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer))); vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer)));
float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05; float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05;
// Two-pass alpha-test (N.5 Decision 2).
// A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE
// derives the MSAA sample mask from it — ClipMap foliage edges become smooth.
// Discard only fully-transparent (α < 0.05); the GPU handles coverage masking.
if (uRenderPass == 0) {
// '<' spells D3DCMP_GREATEREQUAL: equality passes. Retail loads
// 100 or 200 and func 7 at SetSurface 0x0059c73c..0x0059c838.
if (color.a < alphaCutoff) discard;
} else {
// Transparent pass.
//
// Phase Post-A.5 (ISSUE #52, 2026-05-10): do NOT discard α≥0.95 here.
// Native AC transparent-flagged surfaces routinely include
// effectively-opaque pixels — e.g. the Holtburg lifestone crystal core
// (surface 0x080011DE) which the spawn manifest classifies as
// transparent (batch.IsTransparent=True) but whose decoded texture
// alpha lands ≥0.95 across the visible surface. Those pixels still
// compose correctly under (SrcAlpha, 1-SrcAlpha) alpha-blending, so
// discarding them here threw away the whole crystal. The original
// N.5 §2 rationale (high-α fragments belong in the opaque pass) does
// not apply when the SURFACE is dat-flagged transparent — those
// pixels can't reach the opaque pass at all.
//
// Keep the α<0.05 short-circuit as a fragment-cost optimization
// (skip fully-empty pixels — saves blend bandwidth on alpha-keyed
// sprites with large transparent margins).
if (color.a < alphaCutoff) discard;
}
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped // Per-vertex Gouraud lighting from the vertex shader (ambient + capped
// lights), passed through retail CMaterial's two click-pulse replacements: // lights), passed through retail CMaterial's two click-pulse replacements:
// emissive/luminosity + diffuse * scene lighting. Normal is (0,1). // emissive/luminosity + diffuse * scene lighting. Normal is (0,1).
@ -108,6 +86,7 @@ void main() {
// ignored). Stripes visible HERE = a vertex-lighting artifact; absent = // ignored). Stripes visible HERE = a vertex-lighting artifact; absent =
// the pattern comes from texture/per-pixel machinery. Throwaway diagnostic. // the pattern comes from texture/per-pixel machinery. Throwaway diagnostic.
if (uLightDebug == 3) { if (uLightDebug == 3) {
if (color.a < alphaCutoff) discard;
FragColor = vec4(min(lit, vec3(1.0)), 1.0); FragColor = vec4(min(lit, vec3(1.0)), 1.0);
return; return;
} }
@ -118,11 +97,32 @@ void main() {
// Retail clamp per-channel to 1.0 (r13 §13.1). // Retail clamp per-channel to 1.0 (r13 §13.1).
lit = min(lit, vec3(1.0)); lit = min(lit, vec3(1.0));
vec3 rgb = color.rgb * lit; bool detailActive = uParamA != 0.0
&& vDetailCategory != 0u
&& (vBatchFlags & 1u) != 0u;
vec3 rgb;
float alpha;
if (detailActive) {
vec4 detail = ACDREAM_SAMPLE_ARRAY(
uTextureIndexA,
vec3(vTexCoord * uParamA, 0.0));
RetailDetailMaterialResult material = acdreamRetailDetailMaterial(
color.rgb,
lit,
detail,
vSurfaceOpacity * vOpacityMultiplier);
rgb = material.rgb;
alpha = material.alpha;
} else {
rgb = color.rgb * lit;
alpha = color.a * vOpacityMultiplier;
}
// '<' spells D3DCMP_GREATEREQUAL: equality passes. Detail-active CLIP
// tests retail's final stage-1 alpha; detail-off keeps base-alpha logic.
if (detailActive ? alpha < alphaCutoff : color.a < alphaCutoff)
discard;
rgb = applyFog(rgb, vWorldPos); rgb = applyFog(rgb, vWorldPos);
// #188: multiply the FINAL alpha only — the discard thresholds above stay FragColor = vec4(rgb, alpha);
// keyed on the raw sampled color.a, so the last few frames of a fade
// (multiplier crossing under 0.05) still ramp smoothly toward zero rather
// than popping invisible early against the discard cutoff.
FragColor = vec4(rgb, color.a * vOpacityMultiplier);
} }

View file

@ -19,7 +19,7 @@ struct InstanceData {
// CPU writer's layout is unchanged (GpuBindingModel.GpuBatchDataStrideBytes). // CPU writer's layout is unchanged (GpuBindingModel.GpuBatchDataStrideBytes).
struct BatchData { struct BatchData {
uint textureIndex; // slot into the global texture table uint textureIndex; // slot into the global texture table
uint _pad; // keeps textureLayer/flags at offsets 8/12 float surfaceOpacity; // authored material alpha (not base texture alpha)
uint textureLayer; // layer in the shared WB or pooled composite array uint textureLayer; // layer in the shared WB or pooled composite array
uint flags; // reserved — N.5 dispatcher owns all blend state uint flags; // reserved — N.5 dispatcher owns all blend state
// (glBlendFunc per pass). If a future phase wants // (glBlendFunc per pass). If a future phase wants
@ -112,6 +112,10 @@ layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf {
vec2 instanceSelectionLighting[]; vec2 instanceSelectionLighting[];
}; };
layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
uint instanceDetailCategory[];
};
uniform mat4 uViewProjection; uniform mat4 uViewProjection;
// Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[]. // Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[].
@ -272,6 +276,9 @@ out flat uint vTextureIndex;
out flat uint vTextureLayer; out flat uint vTextureLayer;
out flat float vOpacityMultiplier; // #188 out flat float vOpacityMultiplier; // #188
out flat vec2 vSelectionLighting; out flat vec2 vSelectionLighting;
out flat float vSurfaceOpacity;
out flat uint vBatchFlags;
out flat uint vDetailCategory;
void main() { void main() {
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
@ -295,4 +302,7 @@ void main() {
// site in mesh_modern.frag, which is the only form Vulkan can express. // site in mesh_modern.frag, which is the only form Vulkan can express.
vTextureIndex = b.textureIndex; vTextureIndex = b.textureIndex;
vTextureLayer = b.textureLayer; vTextureLayer = b.textureLayer;
vSurfaceOpacity = b.surfaceOpacity;
vBatchFlags = b.flags;
vDetailCategory = instanceDetailCategory[instanceIndex];
} }

View file

@ -0,0 +1,18 @@
// D3DPolyRender::SetSurface @0x0059C4D0, retail single-pass detail stages.
// Keep this one source shared by the ordinary and atmospheric mesh families.
struct RetailDetailMaterialResult {
vec3 rgb;
float alpha;
};
RetailDetailMaterialResult acdreamRetailDetailMaterial(
vec3 baseRgb,
vec3 diffuseRgb,
vec4 detail,
float materialAlpha)
{
float weight = materialAlpha * detail.a;
return RetailDetailMaterialResult(
detail.rgb * weight + (baseRgb * diffuseRgb) * (1.0 - weight),
materialAlpha * detail.a * detail.a);
}

View file

@ -215,28 +215,12 @@
"stages": [ "stages": [
{ {
"stage": "vert", "stage": "vert",
"sourceSha256": "e552edb7ef42e9e32812824c075310f2d99eb5826e527db5c22b54890ef02088", "sourceSha256": "71950823770a69d610db03e0c54c645934e3b46a686c8abac85b52f2476afcfa",
"compiled": true "compiled": true
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "62b001a72080ea74bdaef0fb2a4ed92533feaeab76187ce4215813c6327432e7", "sourceSha256": "5eb058e92ab27f1f6c245401e05f78a278fe00696b5c77f36ae33f8428f83bf3",
"compiled": true
}
]
},
{
"name": "mesh_detail",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "2d8b5b42f053a2d42e1ff44db3e331d5bea2d3f7dd36e20d813b7ae22c033181",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "a5285c51e8d6a1fa91f403f8489819595fa5a20abaaaccc969f220ea2c4eb335",
"compiled": true "compiled": true
} }
] ]
@ -247,12 +231,12 @@
"stages": [ "stages": [
{ {
"stage": "vert", "stage": "vert",
"sourceSha256": "dba51ce23f2b621683fc78db73f608a0c000479b8e827cd3ecc8731d46c6ee64", "sourceSha256": "c41448edcd258313f921f3b66c0884c8f78d4d592eef1abe0804c6cb2890c58f",
"compiled": true "compiled": true
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "d6ae09de279e814b159e7c481d6b66f3cc37c741b3d8a249fbe2bc8156e52d4e", "sourceSha256": "396c382af73505bc2cac7a2db5878a63166730e47096a94630c35ad8376fe565",
"compiled": true "compiled": true
} }
] ]

View file

@ -1094,11 +1094,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
/// this method. This list's premise is the opaque-only, depth-test-and- /// this method. This list's premise is the opaque-only, depth-test-and-
/// write, no-blend pipeline pair the walk's ordered stream actually uses /// write, no-blend pipeline pair the walk's ordered stream actually uses
/// TODAY — not <c>StreamMark</c> itself: if any future ordered-stream /// TODAY — not <c>StreamMark</c> itself: if any future ordered-stream
/// stage draws with blending or a disabled depth write (for example the /// stage draws with blending or a disabled depth write, that stage's
/// deferred building-detail overlay replay, /// <c>StreamMark</c> MUST
/// <c>WbDrawDispatcher.DrawBuildingDetailRangeRhi</c>'s
/// <c>RetailDetailTransparent</c> pass — not wired into this walk path
/// today, OrderedStream.cs:46-52), that stage's <c>StreamMark</c> MUST
/// become a flush point again; the contract gets amended, not bent.</para> /// become a flush point again; the contract gets amended, not bent.</para>
/// </summary> /// </summary>
internal void Replay(IGpuFrame frame, IGpuPassEncoder encoder) internal void Replay(IGpuFrame frame, IGpuPassEncoder encoder)

View file

@ -28,9 +28,6 @@ public sealed unsafe partial class EnvCellRenderer
private IGpuPipeline? _alphaPipeline; private IGpuPipeline? _alphaPipeline;
private IGpuPipeline? _clipPipeline; private IGpuPipeline? _clipPipeline;
private IGpuPipeline? _additivePipeline; private IGpuPipeline? _additivePipeline;
private IGpuPipeline? _detailPipeline;
private IGpuPipeline? _transparentDetailPipeline;
private IGpuPipeline? _clipDetailPipeline;
private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail; private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail;
private readonly Func<bool> _buildingDetailEnabled; private readonly Func<bool> _buildingDetailEnabled;
@ -46,11 +43,12 @@ public sealed unsafe partial class EnvCellRenderer
/// to the retail alpha queue for later replay. /// to the retail alpha queue for later replay.
/// </summary> /// </summary>
internal bool TransparentDetailEnabled => internal bool TransparentDetailEnabled =>
RetailDetailTextureContract.ShouldRender(_buildingDetailEnabled(), _environmentDetail); RetailDetailTextureContract.ShouldRender(_buildingDetailEnabled(), _environmentDetail)
&& _environmentDetail.Tiling != 0f;
/// <summary> /// <summary>
/// The RHI arm's constructor. It also completes <c>Initialize</c>'s job: the /// The RHI arm's constructor. It also completes <c>Initialize</c>'s job: the
/// seven pipelines ARE this renderer's program, so there is no second step /// four pipelines ARE this renderer's program, so there is no second step
/// and no <c>Shader</c> to hand in. /// and no <c>Shader</c> to hand in.
/// </summary> /// </summary>
internal EnvCellRenderer( internal EnvCellRenderer(
@ -82,33 +80,6 @@ public sealed unsafe partial class EnvCellRenderer
scope.SampleCount); scope.SampleCount);
_additivePipeline = CreateShellPipeline( _additivePipeline = CreateShellPipeline(
device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount); device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount);
_detailPipeline = CreateShellPipeline(
device,
"envcell-retail-detail",
GpuBlendMode.RetailDetail,
depthWrite: true,
scope.SampleCount,
shaderName: "mesh_detail",
depthCompare: RetailDetailTextureContract.DetailDepthCompare(
transparent: false));
_transparentDetailPipeline = CreateShellPipeline(
device,
"envcell-retail-detail-alpha",
GpuBlendMode.RetailDetail,
depthWrite: false,
scope.SampleCount,
shaderName: "mesh_detail",
depthCompare: RetailDetailTextureContract.DetailDepthCompare(
transparent: true));
_clipDetailPipeline = CreateShellPipeline(
device,
"envcell-retail-detail-clip",
GpuBlendMode.RetailDetail,
depthWrite: true,
scope.SampleCount,
shaderName: "mesh_detail",
depthCompare: RetailDetailTextureContract.DetailDepthCompare(
transparent: false));
_initialized = true; _initialized = true;
} }
@ -267,7 +238,7 @@ public sealed unsafe partial class EnvCellRenderer
BindRingSection<int>( BindRingSection<int>(
encoder, frame, GpuBindingModel.StorageInstanceLightSets, encoder, frame, GpuBindingModel.StorageInstanceLightSets,
_lightSetData.AsSpan(0, uniqueInstanceCount * lightStride)); _lightSetData.AsSpan(0, uniqueInstanceCount * lightStride));
BindEnvironmentDetailCategory(encoder, frame); BindEnvironmentDetailCategory(encoder, frame, uniqueInstanceCount);
// The frame-global sections, bound after this renderer's own binds // The frame-global sections, bound after this renderer's own binds
// because those binds are what select the descriptor scope. // because those binds are what select the descriptor scope.
@ -282,9 +253,7 @@ public sealed unsafe partial class EnvCellRenderer
MemoryMarshal.AsBytes(_commands.AsSpan(0, totalDraws)).CopyTo(commands.Data); MemoryMarshal.AsBytes(_commands.AsSpan(0, totalDraws)).CopyTo(commands.Data);
IGpuBuffer commandBuffer = commands.Buffer; IGpuBuffer commandBuffer = commands.Buffer;
uint commandBase = commands.OffsetBytes; uint commandBase = commands.OffsetBytes;
bool detailEnabled = RetailDetailTextureContract.ShouldRender( bool detailEnabled = TransparentDetailEnabled;
_buildingDetailEnabled(),
_environmentDetail);
for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++)
{ {
@ -324,56 +293,14 @@ public sealed unsafe partial class EnvCellRenderer
pushConstants.ParamB = isClip pushConstants.ParamB = isClip
? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f ? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
: 0f; : 0f;
pushConstants.TextureIndexA = detailEnabled
? _environmentDetail.TextureSlot.Index
: 0u;
pushConstants.ParamA = detailEnabled
? _environmentDetail.Tiling
: 0f;
encoder.SetPushConstants(in pushConstants); encoder.SetPushConstants(in pushConstants);
// Retail DrawMesh's two-pass fallback redraws each transparent
// RenderMeshSubset immediately, before the next delayed-alpha
// subset. Preserve that base/detail adjacency so another shell or
// particle cannot be composited between the two contributions.
if (renderPass == WbRenderPass.Transparent && detailEnabled)
{
int rangeEnd = drawRange.FirstCommand + drawRange.CommandCount;
for (int command = drawRange.FirstCommand; command < rangeEnd; command++)
{
BindPipelineWithMesh(encoder, rangeBasePipeline, mesh);
SetCullMode(encoder, cullMode);
pushConstants.RenderPass = isAdditive
? (int)renderPass | 0x100
: (int)renderPass;
pushConstants.DrawIdOffset = command;
pushConstants.TextureIndexA = 0;
pushConstants.ParamA = 0f;
pushConstants.ParamB = isClip
? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
: 0f;
encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect(
commandBuffer,
commandBase + (uint)(command * sizeof(DrawElementsIndirectCommand)),
1,
(uint)sizeof(DrawElementsIndirectCommand));
BindPipelineWithMesh(
encoder,
isClip ? _clipDetailPipeline! : _transparentDetailPipeline!,
mesh);
SetCullMode(encoder, cullMode);
pushConstants.DrawIdOffset = command;
pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index;
pushConstants.ParamA = _environmentDetail.Tiling;
pushConstants.ParamB = isClip
? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
: 0f;
encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect(
commandBuffer,
commandBase + (uint)(command * sizeof(DrawElementsIndirectCommand)),
1,
(uint)sizeof(DrawElementsIndirectCommand));
}
continue;
}
encoder.MultiDrawIndexedIndirect( encoder.MultiDrawIndexedIndirect(
commandBuffer, commandBuffer,
commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)),
@ -381,39 +308,10 @@ public sealed unsafe partial class EnvCellRenderer
(uint)sizeof(DrawElementsIndirectCommand)); (uint)sizeof(DrawElementsIndirectCommand));
} }
// Retail DrawEnvCell category (2). Replay only the already-filtered pushConstants.TextureIndexA = 0;
// opaque shell commands. Transparent ClipMap is excluded here: it pushConstants.ParamA = 0f;
// takes the immediate/delayed CLIP path and, when detail is active, pushConstants.ParamB = 0f;
// its own _clipDetailPipeline contribution. No distance fade encoder.SetPushConstants(in pushConstants);
// (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)
{
BindPipelineWithMesh(encoder, _detailPipeline!, mesh);
pushConstants.RenderPass = 0;
pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index;
pushConstants.ParamA = _environmentDetail.Tiling;
pushConstants.ParamB = 0f;
for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++)
{
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
CullMode cullMode = ResolveRetailCellShellCullMode(
(CullMode)(drawRange.GroupIndex % CullGroupCount));
SetCullMode(encoder, cullMode);
pushConstants.DrawIdOffset = drawRange.FirstCommand;
encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect(
commandBuffer,
commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)),
(uint)drawRange.CommandCount,
(uint)sizeof(DrawElementsIndirectCommand));
}
}
} }
private void BindPipelineWithMesh( private void BindPipelineWithMesh(
@ -502,23 +400,23 @@ public sealed unsafe partial class EnvCellRenderer
} }
/// <summary> /// <summary>
/// Binds one category word for <c>mesh_detail.vert</c>'s statically used /// Binds one EnvCell category word per instance for the world vertex
/// binding 9. The vertex shader treats only exact <c>uParamB=1</c> as the /// shader's statically used binding 9. Vulkan requires the declared
/// object/building-category sentinel; ordinary EnvCell zero and the CLIP /// descriptor to be valid even when detail is unarmed.
/// references 100/255 and 200/255 therefore keep the environment category.
/// Vulkan still requires the declared descriptor to be valid.
/// </summary> /// </summary>
internal static void BindEnvironmentDetailCategory( internal void BindEnvironmentDetailCategory(
IGpuPassEncoder encoder, IGpuPassEncoder encoder,
IGpuFrame frame) IGpuFrame frame,
int instanceCount)
{ {
Span<uint> category = stackalloc uint[1]; if (_detailCategoryData.Length < instanceCount)
category[0] = 1u; Array.Resize(ref _detailCategoryData, Math.Max(instanceCount, 16));
Array.Fill(_detailCategoryData, 1u, 0, instanceCount);
BindRingSection<uint>( BindRingSection<uint>(
encoder, encoder,
frame, frame,
GpuBindingModel.StorageInstanceDetailCategory, GpuBindingModel.StorageInstanceDetailCategory,
category); _detailCategoryData.AsSpan(0, instanceCount));
} }
private void DisposeRhiResources() private void DisposeRhiResources()
@ -531,11 +429,5 @@ public sealed unsafe partial class EnvCellRenderer
_clipPipeline = null; _clipPipeline = null;
_additivePipeline?.Dispose(); _additivePipeline?.Dispose();
_additivePipeline = null; _additivePipeline = null;
_detailPipeline?.Dispose();
_detailPipeline = null;
_transparentDetailPipeline?.Dispose();
_transparentDetailPipeline = null;
_clipDetailPipeline?.Dispose();
_clipDetailPipeline = null;
} }
} }

View file

@ -93,10 +93,10 @@ public sealed partial class EnvCellRenderer :
private uint[] _clipSlotData = Array.Empty<uint>(); private uint[] _clipSlotData = Array.Empty<uint>();
// Campaign VM VM1 follow-up: per-instance opacity multiplier, parallel to // Campaign VM VM1 follow-up: per-instance opacity multiplier, parallel to
// _gpuInstanceTransforms, feeding mesh_modern.vert/mesh_detail.vert's // _gpuInstanceTransforms, feeding both world vertex families'
// InstanceAlphaBuf (binding 7, GpuBindingModel.StorageInstanceAlpha). // InstanceAlphaBuf (binding 7, GpuBindingModel.StorageInstanceAlpha).
// Before this field the shell and interior-detail passes never bound that // Before this field the shell pass never bound that storage buffer at
// storage buffer at all, so both shaders read whatever section // all, so the shader read whatever section
// WbDrawDispatcher's own SubmitRhi last bound in the same pass — an // WbDrawDispatcher's own SubmitRhi last bound in the same pass — an
// unrelated object's opacity array, indexed by these cell instance ids. // unrelated object's opacity array, indexed by these cell instance ids.
// EnvCell shells never carry a #188 TransparentPartHook translucency fade // EnvCell shells never carry a #188 TransparentPartHook translucency fade
@ -137,6 +137,7 @@ public sealed partial class EnvCellRenderer :
// WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>() // WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>()
private DrawElementsIndirectCommand[] _commands = Array.Empty<DrawElementsIndirectCommand>(); private DrawElementsIndirectCommand[] _commands = Array.Empty<DrawElementsIndirectCommand>();
private ModernBatchData[] _modernBatches = Array.Empty<ModernBatchData>(); private ModernBatchData[] _modernBatches = Array.Empty<ModernBatchData>();
private uint[] _detailCategoryData = Array.Empty<uint>();
private readonly List<EnvCellLandblock> _prepareLandblocks = new(); private readonly List<EnvCellLandblock> _prepareLandblocks = new();
private readonly List<InstanceData> _renderInstances = new(); private readonly List<InstanceData> _renderInstances = new();
private readonly List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> _renderDrawCalls = new(); private readonly List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> _renderDrawCalls = new();
@ -882,7 +883,7 @@ public sealed partial class EnvCellRenderer :
EnvCellTransparentRoute transparentRoute, EnvCellTransparentRoute transparentRoute,
bool detailSurfaceActive) bool detailSurfaceActive)
{ {
// WB EnvCellRenderManager.cs:400: the RHI arm's three pipelines are built // WB EnvCellRenderManager.cs:400: the RHI arm's four pipelines are built
// at construction (see EnvCellRenderer.Rhi.cs), so _initialized alone // at construction (see EnvCellRenderer.Rhi.cs), so _initialized alone
// answers whether this renderer is ready to draw. // answers whether this renderer is ready to draw.
if (!_initialized) return; if (!_initialized) return;
@ -1312,6 +1313,7 @@ public sealed partial class EnvCellRenderer :
// Slice V4t: the slot is the device's, assigned by the // Slice V4t: the slot is the device's, assigned by the
// mesh manager at upload rather than interned here. // mesh manager at upload rather than interned here.
TextureTableIndex = item.batch.TextureSlot.Index, TextureTableIndex = item.batch.TextureSlot.Index,
SurfaceOpacity = item.batch.SurfaceOpacity,
TextureIndex = (uint)item.batch.TextureIndex, TextureIndex = (uint)item.batch.TextureIndex,
// #226: this renderer submits built EnvCell meshes. // #226: this renderer submits built EnvCell meshes.
// Retail DrawMesh forwards curr_detail_surface to // Retail DrawMesh forwards curr_detail_surface to

View file

@ -6,7 +6,7 @@ namespace AcDream.App.Rendering.Wb;
/// <summary> /// <summary>
/// Campaign VM VM6: the two <c>BatchData.flags</c> bits that mark a world /// Campaign VM VM6: the two <c>BatchData.flags</c> bits that mark a world
/// draw batch as swaying foliage — bit 0 remains #226's built-mesh marker /// draw batch as swaying foliage — bit 0 remains #226's built-mesh marker
/// (see <c>mesh_detail.frag</c>'s <c>vBatchFlags &amp; 1u</c> check) and is /// (see the world fragment shaders' <c>vBatchFlags &amp; 1u</c> check) and is
/// never touched here. <c>mesh_atmospheric.vert</c> and the four /// never touched here. <c>mesh_atmospheric.vert</c> and the four
/// <c>directional_shadow_world_*.vert</c> caster shaders read these bits /// <c>directional_shadow_world_*.vert</c> caster shaders read these bits
/// through the shared <c>foliage_wind.glsl</c> include /// through the shared <c>foliage_wind.glsl</c> include

View file

@ -52,4 +52,5 @@ internal readonly record struct GroupKey(
uint TextureLayer, uint TextureLayer,
TranslucencyKind Translucency, TranslucencyKind Translucency,
uint FoliageFlags, uint FoliageFlags,
float SurfaceOpacity = 1f,
CullMode CullMode = CullMode.CounterClockwise); CullMode CullMode = CullMode.CounterClockwise);

View file

@ -6,13 +6,13 @@ namespace AcDream.App.Rendering.Wb {
/// Consists of a texture-table slot (Campaign V slice V2; was a raw 64-bit /// Consists of a texture-table slot (Campaign V slice V2; was a raw 64-bit
/// bindless handle) and the layer index within the shared/pooled array. /// bindless handle) and the layer index within the shared/pooled array.
/// Indexed by gl_DrawIDARB in the vertex shader. Same 16-byte std430 shape /// Indexed by gl_DrawIDARB in the vertex shader. Same 16-byte std430 shape
/// as mesh_modern.vert's BatchData: TextureTableIndex/Reserved/TextureIndex/ /// as mesh_modern.vert's BatchData: TextureTableIndex/SurfaceOpacity/TextureIndex/
/// Flags at offsets 0/4/8/12 — see ModernBatchDataLayoutTests. /// Flags at offsets 0/4/8/12 — see ModernBatchDataLayoutTests.
/// </summary> /// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)] [StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct ModernBatchData { public struct ModernBatchData {
public uint TextureTableIndex; // 4 bytes — slot into the binding=9 handle table public uint TextureTableIndex; // 4 bytes — slot into the binding=9 handle table
public uint Reserved; // 4 bytes — pad, keeps TextureIndex/Flags at offsets 8/12 public float SurfaceOpacity; // 4 bytes — authored material alpha
public uint TextureIndex; // 4 bytes — layer within the texture array public uint TextureIndex; // 4 bytes — layer within the texture array
public uint Flags; // 4 bytes — reserved, matches mesh_modern.vert's BatchData.flags public uint Flags; // 4 bytes — reserved, matches mesh_modern.vert's BatchData.flags
} }

View file

@ -123,6 +123,8 @@ namespace AcDream.App.Rendering.Wb
public bool IsTransparent { get; set; } public bool IsTransparent { get; set; }
public bool IsAdditive { get; set; } public bool IsAdditive { get; set; }
public bool HasWrappingUVs { get; set; } public bool HasWrappingUVs { get; set; }
/// <summary>Authored material alpha; base texture alpha is separate.</summary>
public float SurfaceOpacity { get; set; } = 1f;
// Modern rendering path fields // Modern rendering path fields
public uint FirstIndex { get; set; } public uint FirstIndex { get; set; }
@ -2263,6 +2265,7 @@ namespace AcDream.App.Rendering.Wb
IsTransparent = batch.IsTransparent, IsTransparent = batch.IsTransparent,
IsAdditive = batch.IsAdditive, IsAdditive = batch.IsAdditive,
HasWrappingUVs = batch.HasWrappingUVs, HasWrappingUVs = batch.HasWrappingUVs,
SurfaceOpacity = batch.SurfaceOpacity,
Key = batch.Key, Key = batch.Key,
CullMode = batch.CullMode, CullMode = batch.CullMode,
FirstIndex = firstIndex, FirstIndex = firstIndex,

View file

@ -42,14 +42,9 @@ namespace AcDream.App.Rendering.Wb;
/// calls the pair (prepare once, draw the whole stream as one range, or as /// calls the pair (prepare once, draw the whole stream as one range, or as
/// several — see <c>OrderPreservingSubmitterTests</c>).</para> /// several — see <c>OrderPreservingSubmitterTests</c>).</para>
/// ///
/// <para>Scope: this stage proves walk-order submission through the existing /// <para>OVERHAUL S5-c4 arms retail detail on a detail-category command's
/// RHI on static content (plan §FW2). It does NOT wire the retail /// existing solo base run. It remains one draw and therefore introduces no
/// building-detail overlay replay (<c>DrawBuildingDetailRangeRhi</c>'s second /// blended overlay or additional terrain/StreamMark flush boundary.</para>
/// pass through <c>RetailDetail</c>/<c>RetailDetailTransparent</c>) — a
/// detail-category command still forces a solo merge run (mirroring the
/// deferred-alpha detail break), but this submitter issues only the
/// base-pipeline draw for it. The overlay replay is production wiring,
/// deferred to whichever stage cuts the walk over for real content.</para>
/// </summary> /// </summary>
public sealed unsafe partial class WbDrawDispatcher public sealed unsafe partial class WbDrawDispatcher
{ {
@ -395,6 +390,7 @@ public sealed unsafe partial class WbDrawDispatcher
_batchData[i] = new BatchData _batchData[i] = new BatchData
{ {
TextureIndex = key.TextureSlot.Index, TextureIndex = key.TextureSlot.Index,
SurfaceOpacity = key.SurfaceOpacity,
TextureLayer = key.TextureLayer, TextureLayer = key.TextureLayer,
Flags = 1u | key.FoliageFlags, Flags = 1u | key.FoliageFlags,
}; };
@ -545,6 +541,10 @@ public sealed unsafe partial class WbDrawDispatcher
IGpuBuffer commandBuffer = _orderedCommands.Buffer!; IGpuBuffer commandBuffer = _orderedCommands.Buffer!;
uint commandBase = _orderedCommands.OffsetBytes; uint commandBase = _orderedCommands.OffsetBytes;
int rangeEnd = firstCommand + commandCount; int rangeEnd = firstCommand + commandCount;
bool detailEnabled = RetailDetailTextureContract.ShouldRender(
_buildingDetailEnabled(),
_buildingDetail)
&& _buildingDetail.Tiling != 0f;
foreach (OrderedMergeRun run in _orderedRuns) foreach (OrderedMergeRun run in _orderedRuns)
{ {
@ -569,6 +569,11 @@ public sealed unsafe partial class WbDrawDispatcher
PipelineBucket bucket = BucketFor(_orderedStream.Keys[run.FirstCommand].Translucency); PipelineBucket bucket = BucketFor(_orderedStream.Keys[run.FirstCommand].Translucency);
IGpuPipeline pipeline = PipelineForBucket(pipelines, bucket); IGpuPipeline pipeline = PipelineForBucket(pipelines, bucket);
pushConstants.RenderPass = bucket == PipelineBucket.Opaque ? 0 : 1; pushConstants.RenderPass = bucket == PipelineBucket.Opaque ? 0 : 1;
if (detailEnabled
&& _orderedStream.DetailCategories[run.FirstCommand] != 0u)
ArmBuildingDetail(ref pushConstants);
else
ClearDetailPushConstants(ref pushConstants);
BindPipelineWithMesh(encoder, pipeline, global); BindPipelineWithMesh(encoder, pipeline, global);
DrawIndirectRangeRhi( DrawIndirectRangeRhi(
@ -580,6 +585,9 @@ public sealed unsafe partial class WbDrawDispatcher
run.FirstCommand, run.FirstCommand,
run.CommandCount); run.CommandCount);
} }
ClearDetailPushConstants(ref pushConstants);
encoder.SetPushConstants(in pushConstants);
} }
/// <summary> /// <summary>

View file

@ -33,7 +33,7 @@ public sealed unsafe partial class WbDrawDispatcher
private readonly IWorldPassScope? _scope; private readonly IWorldPassScope? _scope;
/// <summary> /// <summary>
/// The seven mesh pipelines at ONE sample count. /// The five retail material/blend pipelines at ONE sample count.
/// ///
/// <para>Campaign V slice V6l: there are two of these. Vulkan requires a /// <para>Campaign V slice V6l: there are two of these. Vulkan requires a
/// pipeline's <c>rasterizationSamples</c> to equal the pass it draws in, and /// pipeline's <c>rasterizationSamples</c> to equal the pass it draws in, and
@ -52,16 +52,13 @@ public sealed unsafe partial class WbDrawDispatcher
IGpuPipeline OpaqueAlphaToCoverage, IGpuPipeline OpaqueAlphaToCoverage,
IGpuPipeline AlphaBlend, IGpuPipeline AlphaBlend,
IGpuPipeline AlphaAdditive, IGpuPipeline AlphaAdditive,
IGpuPipeline AlphaInverse, IGpuPipeline AlphaInverse);
IGpuPipeline RetailDetail,
IGpuPipeline RetailDetailTransparent);
private MeshPipelineSet? _backbufferPipelines; private MeshPipelineSet? _backbufferPipelines;
private MeshPipelineSet? _offscreenPipelines; private MeshPipelineSet? _offscreenPipelines;
private const string OpaqueTimerScope = "wb-entities-opaque"; private const string OpaqueTimerScope = "wb-entities-opaque";
private const string TransparentTimerScope = "wb-entities-transparent"; private const string TransparentTimerScope = "wb-entities-transparent";
private const string DetailTimerScope = "wb-buildings-detail";
private readonly TerrainAtlas.RetailDetailTextureBinding _buildingDetail; private readonly TerrainAtlas.RetailDetailTextureBinding _buildingDetail;
private readonly Func<bool> _buildingDetailEnabled; private readonly Func<bool> _buildingDetailEnabled;
@ -158,8 +155,7 @@ public sealed unsafe partial class WbDrawDispatcher
/// <summary> /// <summary>
/// The RHI arm's constructor. No GL context, no <c>Shader</c>, no /// The RHI arm's constructor. No GL context, no <c>Shader</c>, no
/// <c>BindlessSupport</c>: five base pipelines compile <c>mesh_modern</c> and /// <c>BindlessSupport</c>: five base pipelines compile <c>mesh_modern</c>;
/// the detail overlay compiles <c>mesh_detail</c> from committed SPIR-V;
/// batch data already carries the device's own /// batch data already carries the device's own
/// <c>GpuTextureSlot</c> (V4t) rather than a bindless handle. /// <c>GpuTextureSlot</c> (V4t) rather than a bindless handle.
/// </summary> /// </summary>
@ -231,7 +227,7 @@ public sealed unsafe partial class WbDrawDispatcher
bool usesRenderPackShaderAbi = false) bool usesRenderPackShaderAbi = false)
{ {
string suffix = samples > 1 ? string.Empty : "-1x"; string suffix = samples > 1 ? string.Empty : "-1x";
var created = new List<IGpuPipeline>(7); var created = new List<IGpuPipeline>(5);
try try
{ {
return new MeshPipelineSet( return new MeshPipelineSet(
@ -260,28 +256,6 @@ public sealed unsafe partial class WbDrawDispatcher
device, $"{namePrefix}-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples, device, $"{namePrefix}-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples,
shaders: baseShaders, shaders: baseShaders,
shaderName: baseShaderName, shaderName: baseShaderName,
usesRenderPackShaderAbi: usesRenderPackShaderAbi)),
Track(CreateMeshPipeline(
device,
$"wb-mesh-retail-detail{suffix}",
GpuBlendMode.RetailDetail,
true,
false,
samples,
shaderName: "mesh_detail",
depthCompare: RetailDetailTextureContract.DetailDepthCompare(
transparent: false),
usesRenderPackShaderAbi: usesRenderPackShaderAbi)),
Track(CreateMeshPipeline(
device,
$"wb-mesh-retail-detail-alpha{suffix}",
GpuBlendMode.RetailDetail,
false,
false,
samples,
shaderName: "mesh_detail",
depthCompare: RetailDetailTextureContract.DetailDepthCompare(
transparent: true),
usesRenderPackShaderAbi: usesRenderPackShaderAbi))); usesRenderPackShaderAbi: usesRenderPackShaderAbi)));
} }
catch catch
@ -441,10 +415,12 @@ public sealed unsafe partial class WbDrawDispatcher
transformBaseInstance); transformBaseInstance);
IGpuBuffer commandBuffer = commands.Buffer; IGpuBuffer commandBuffer = commands.Buffer;
uint commandBase = commands.OffsetBytes; uint commandBase = commands.OffsetBytes;
ReadOnlySpan<DrawElementsIndirectCommand> usedCommands =
_indirectCommands.AsSpan(0, totalDraws);
ReadOnlySpan<uint> usedDetailCategories = ReadOnlySpan<uint> usedDetailCategories =
_detailCategoryData.AsSpan(0, immediateInstances); _detailCategoryData.AsSpan(0, immediateInstances);
bool detailEnabled = RetailDetailTextureContract.ShouldRender(
_buildingDetailEnabled(),
_buildingDetail)
&& _buildingDetail.Tiling != 0f;
// ── Phase 7: opaque pass ───────────────────────────────────────────── // ── Phase 7: opaque pass ─────────────────────────────────────────────
if (_opaqueDrawCount > 0) if (_opaqueDrawCount > 0)
@ -459,67 +435,12 @@ public sealed unsafe partial class WbDrawDispatcher
encoder.SetPushConstants(in pushConstants); encoder.SetPushConstants(in pushConstants);
using (BeginRhiTimer(encoder, diag, OpaqueTimerScope)) using (BeginRhiTimer(encoder, diag, OpaqueTimerScope))
{ {
DrawIndirectRangeRhi( DrawDetailAwareRangeRhi(
encoder, ref pushConstants, commandBuffer, commandBase, encoder, ref pushConstants, commandBuffer, commandBase,
0, _opaqueDrawCount); 0, _opaqueDrawCount, usedDetailCategories, detailEnabled);
} }
} }
// Retail DrawBuilding detail category (1). Only consecutive command
// runs containing a building are replayed; mesh_detail filters ordinary
// instances inside a mixed command. This includes ClipMap built-mesh
// subsets: the named retail
// DrawMesh path forwards curr_detail_surface to RenderMeshSubset for
// every material kind. The setting is read at draw time so the existing
// Options checkbox changes the live scene immediately.
if (_opaqueDrawCount > 0
&& RetailDetailTextureContract.ShouldRender(
_buildingDetailEnabled(),
_buildingDetail))
{
int searchStart = 0;
if (TryGetNextDetailCommandRun(
usedCommands,
usedDetailCategories,
searchStart,
_opaqueDrawCount,
out DetailCommandRun run))
{
BindPipelineWithMesh(encoder, pipelines.RetailDetail, mesh);
pushConstants.RenderPass = 0;
pushConstants.DrawIdOffset = 0;
pushConstants.TextureIndexA = _buildingDetail.TextureSlot.Index;
pushConstants.ParamA = _buildingDetail.Tiling;
pushConstants.ParamB = 1f;
encoder.SetPushConstants(in pushConstants);
using (BeginRhiTimer(encoder, diag, DetailTimerScope))
{
do
{
DrawIndirectRangeRhi(
encoder,
ref pushConstants,
commandBuffer,
commandBase,
run.FirstCommand,
run.CommandCount);
searchStart = run.FirstCommand + run.CommandCount;
}
while (TryGetNextDetailCommandRun(
usedCommands,
usedDetailCategories,
searchStart,
_opaqueDrawCount,
out run));
}
}
// Later base passes expect neutral spare push fields.
pushConstants.TextureIndexA = 0;
pushConstants.ParamA = 0f;
pushConstants.ParamB = 0f;
}
// ── Phase 8: transparent pass ──────────────────────────────────────── // ── Phase 8: transparent pass ────────────────────────────────────────
if (_transparentDrawCount > 0) if (_transparentDrawCount > 0)
{ {
@ -538,7 +459,8 @@ public sealed unsafe partial class WbDrawDispatcher
ref pushConstants, ref pushConstants,
commandBuffer, commandBuffer,
commandBase, commandBase,
usedDetailCategories); usedDetailCategories,
detailEnabled);
} }
} }
@ -594,7 +516,7 @@ public sealed unsafe partial class WbDrawDispatcher
/// difference between the two call sites is <paramref name="viewProjection"/> /// difference between the two call sites is <paramref name="viewProjection"/>
/// (the drain reads the deferred scope's own <c>_deferredAlphaViewProjection</c>; /// (the drain reads the deferred scope's own <c>_deferredAlphaViewProjection</c>;
/// an immediate draw uses whatever the CURRENT frame's viewProjection /// an immediate draw uses whatever the CURRENT frame's viewProjection
/// is) and whether a building-detail overlay pass follows. /// is). The immediate site additionally arms detail on its original draw.
/// </summary> /// </summary>
private GpuPushConstants BindAlphaDrawState( private GpuPushConstants BindAlphaDrawState(
IGpuPassEncoder encoder, IGpuPassEncoder encoder,
@ -703,14 +625,12 @@ public sealed unsafe partial class WbDrawDispatcher
/// <summary> /// <summary>
/// S4-c2 fix round 1 (M3b): <c>DrawMesh</c> row 1's "render immediately; /// S4-c2 fix round 1 (M3b): <c>DrawMesh</c> row 1's "render immediately;
/// enqueue nothing" outcome for ONE translucent building-shell instance /// enqueue nothing" outcome for ONE translucent building-shell instance
/// under building detail — bind the blend pipeline, one indirect draw, /// under building detail — arm the retail one-pass material on the
/// then the detail pass, reusing <see cref="BindAlphaDrawState"/> and /// original blend pipeline and submit the single slot
/// <see cref="DrawBuildingDetailRangeRhi"/> (the same code /// <see cref="WbDrawDispatcher.DrawImmediateAlphaInstance"/> just
/// <see cref="DrawPreparedAlphaBatchRhi"/> used to use) for the single
/// slot <see cref="WbDrawDispatcher.DrawImmediateAlphaInstance"/> just
/// prepared. The caller only reaches this method when /// prepared. The caller only reaches this method when
/// <c>detailSurfaceActive</c> was true, so the detail pass always /// <c>detailSurfaceActive</c> was true, so detail always applies here —
/// applies here — the drain's own per-command detail CHECK is gone /// the drain's own per-command detail CHECK is gone
/// (M3c) precisely because detail-eligible content no longer reaches it. /// (M3c) precisely because detail-eligible content no longer reaches it.
/// </summary> /// </summary>
private void DrawImmediateAlphaInstanceRhi( private void DrawImmediateAlphaInstanceRhi(
@ -726,6 +646,7 @@ public sealed unsafe partial class WbDrawDispatcher
BindPipelineWithMesh(encoder, PipelineForBlend(pipelines, blend), mesh); BindPipelineWithMesh(encoder, PipelineForBlend(pipelines, blend), mesh);
encoder.SetPushConstants(in pushConstants); encoder.SetPushConstants(in pushConstants);
ArmBuildingDetail(ref pushConstants);
DrawIndirectRangeRhi( DrawIndirectRangeRhi(
encoder, encoder,
ref pushConstants, ref pushConstants,
@ -733,16 +654,8 @@ public sealed unsafe partial class WbDrawDispatcher
_alphaCommands.OffsetBytes, _alphaCommands.OffsetBytes,
startCommand: 0, startCommand: 0,
commandCount: 1); commandCount: 1);
ClearDetailPushConstants(ref pushConstants);
DrawBuildingDetailRangeRhi( encoder.SetPushConstants(in pushConstants);
encoder,
mesh,
pipelines.RetailDetailTransparent,
ref pushConstants,
_alphaCommands.Buffer!,
_alphaCommands.OffsetBytes,
firstCommand: 0,
commandCount: 1);
} }
private void DrawImmediateTransparentRhi( private void DrawImmediateTransparentRhi(
@ -752,21 +665,21 @@ public sealed unsafe partial class WbDrawDispatcher
ref GpuPushConstants pushConstants, ref GpuPushConstants pushConstants,
IGpuBuffer commandBuffer, IGpuBuffer commandBuffer,
uint commandBase, uint commandBase,
ReadOnlySpan<uint> usedDetailCategories) ReadOnlySpan<uint> usedDetailCategories,
bool detailEnabled)
{ {
bool detailEnabled = RetailDetailTextureContract.ShouldRender(
_buildingDetailEnabled(),
_buildingDetail);
int command = _opaqueDrawCount; int command = _opaqueDrawCount;
int end = command + _transparentDrawCount; int end = command + _transparentDrawCount;
while (command < end) while (command < end)
{ {
TranslucencyKind blend = _groupInputScratch[command].Translucency;
bool hasDetail = detailEnabled bool hasDetail = detailEnabled
&& CommandContainsDetailCategory( && CommandContainsDetailCategory(
_indirectCommands[command], _indirectCommands[command],
usedDetailCategories); usedDetailCategories);
int runEnd = command + 1; int runEnd = command + 1;
while (runEnd < end while (runEnd < end
&& _groupInputScratch[runEnd].Translucency == blend
&& !hasDetail && !hasDetail
&& (!detailEnabled && (!detailEnabled
|| !CommandContainsDetailCategory( || !CommandContainsDetailCategory(
@ -776,8 +689,14 @@ public sealed unsafe partial class WbDrawDispatcher
runEnd++; runEnd++;
} }
BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh); BindPipelineWithMesh(
ClearDetailPushConstants(ref pushConstants); encoder,
PipelineForBlend(pipelines, blend),
mesh);
if (hasDetail)
ArmBuildingDetail(ref pushConstants);
else
ClearDetailPushConstants(ref pushConstants);
DrawIndirectRangeRhi( DrawIndirectRangeRhi(
encoder, encoder,
ref pushConstants, ref pushConstants,
@ -785,45 +704,60 @@ public sealed unsafe partial class WbDrawDispatcher
commandBase, commandBase,
command, command,
runEnd - command); runEnd - command);
if (hasDetail)
{
DrawBuildingDetailRangeRhi(
encoder,
mesh,
pipelines.RetailDetailTransparent,
ref pushConstants,
commandBuffer,
commandBase,
command,
1);
}
command = runEnd; command = runEnd;
} }
ClearDetailPushConstants(ref pushConstants);
encoder.SetPushConstants(in pushConstants);
} }
private void DrawBuildingDetailRangeRhi( private void DrawDetailAwareRangeRhi(
IGpuPassEncoder encoder, IGpuPassEncoder encoder,
GlobalMeshBuffer mesh,
IGpuPipeline detailPipeline,
ref GpuPushConstants pushConstants, ref GpuPushConstants pushConstants,
IGpuBuffer commandBuffer, IGpuBuffer commandBuffer,
uint commandBase, uint commandBase,
int firstCommand, int firstCommand,
int commandCount) int commandCount,
ReadOnlySpan<uint> detailCategories,
bool detailEnabled)
{
int command = firstCommand;
int end = firstCommand + commandCount;
while (command < end)
{
bool hasDetail = detailEnabled
&& CommandContainsDetailCategory(
_indirectCommands[command], detailCategories);
int runEnd = command + 1;
while (runEnd < end
&& !hasDetail
&& (!detailEnabled
|| !CommandContainsDetailCategory(
_indirectCommands[runEnd], detailCategories)))
{
runEnd++;
}
if (hasDetail)
ArmBuildingDetail(ref pushConstants);
else
ClearDetailPushConstants(ref pushConstants);
DrawIndirectRangeRhi(
encoder,
ref pushConstants,
commandBuffer,
commandBase,
command,
runEnd - command);
command = runEnd;
}
ClearDetailPushConstants(ref pushConstants);
encoder.SetPushConstants(in pushConstants);
}
private void ArmBuildingDetail(ref GpuPushConstants pushConstants)
{ {
BindPipelineWithMesh(encoder, detailPipeline, mesh);
pushConstants.TextureIndexA = _buildingDetail.TextureSlot.Index; pushConstants.TextureIndexA = _buildingDetail.TextureSlot.Index;
pushConstants.ParamA = _buildingDetail.Tiling; pushConstants.ParamA = _buildingDetail.Tiling;
pushConstants.ParamB = 1f;
DrawIndirectRangeRhi(
encoder,
ref pushConstants,
commandBuffer,
commandBase,
firstCommand,
commandCount);
ClearDetailPushConstants(ref pushConstants);
} }
private static void ClearDetailPushConstants( private static void ClearDetailPushConstants(
@ -1139,11 +1073,6 @@ public sealed unsafe partial class WbDrawDispatcher
totalMs += transparentMs; totalMs += transparentMs;
any = true; any = true;
} }
if (_device.Timers.TryResolve(DetailTimerScope, out double detailMs))
{
totalMs += detailMs;
any = true;
}
if (!any) if (!any)
return; return;
@ -1174,8 +1103,6 @@ public sealed unsafe partial class WbDrawDispatcher
pipelines.AlphaBlend.Dispose(); pipelines.AlphaBlend.Dispose();
pipelines.AlphaAdditive.Dispose(); pipelines.AlphaAdditive.Dispose();
pipelines.AlphaInverse.Dispose(); pipelines.AlphaInverse.Dispose();
pipelines.RetailDetail.Dispose();
pipelines.RetailDetailTransparent.Dispose();
} }
private sealed class NullRhiTimerScope : IDisposable private sealed class NullRhiTimerScope : IDisposable

View file

@ -241,6 +241,7 @@ public sealed partial class WbDrawDispatcher
batch.FirstIndex, (int)batch.BaseVertex, batch.FirstIndex, (int)batch.BaseVertex,
batch.IndexCount, texture.Slot, texture.Layer, translucency, batch.IndexCount, texture.Slot, texture.Layer, translucency,
FoliageFlags: foliageFlags, FoliageFlags: foliageFlags,
SurfaceOpacity: batch.SurfaceOpacity,
CullMode: batch.CullMode); CullMode: batch.CullMode);
return true; return true;
} }

View file

@ -617,8 +617,8 @@ public sealed partial class WbDrawDispatcher : IDisposable
private int _transparentDrawCount; private int _transparentDrawCount;
private int _transparentByteOffset; private int _transparentByteOffset;
// Campaign V slice V2 (2026-07-27): std430 layout: uint TextureIndex at // std430 layout: uint TextureIndex at offset 0, float SurfaceOpacity at
// offset 0, uint Reserved (pad) at offset 4, uint TextureLayer at offset 8, // offset 4, uint TextureLayer at offset 8,
// uint Flags at offset 12. Total 16 bytes — unchanged from before V2, so // uint Flags at offset 12. Total 16 bytes — unchanged from before V2, so
// every existing CPU writer's offsets are unchanged (see // every existing CPU writer's offsets are unchanged (see
// GpuBindingModel.GpuBatchDataStrideBytes). TextureIndex used to be a // GpuBindingModel.GpuBatchDataStrideBytes). TextureIndex used to be a
@ -630,7 +630,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
private struct BatchData private struct BatchData
{ {
public uint TextureIndex; // slot into the device texture table public uint TextureIndex; // slot into the device texture table
public uint Reserved; // pad — keeps TextureLayer/Flags at offsets 8/12 public float SurfaceOpacity;
public uint TextureLayer; public uint TextureLayer;
public uint Flags; public uint Flags;
} }
@ -1947,6 +1947,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
_batchData[i] = new BatchData _batchData[i] = new BatchData
{ {
TextureIndex = _batchPublicScratch[i].TextureIndex, TextureIndex = _batchPublicScratch[i].TextureIndex,
SurfaceOpacity = _batchPublicScratch[i].SurfaceOpacity,
TextureLayer = _batchPublicScratch[i].TextureLayer, TextureLayer = _batchPublicScratch[i].TextureLayer,
Flags = _batchPublicScratch[i].Flags, Flags = _batchPublicScratch[i].Flags,
}; };
@ -2049,6 +2050,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
TextureIndex: g.TextureSlot.Index, TextureIndex: g.TextureSlot.Index,
TextureLayer: g.TextureLayer, TextureLayer: g.TextureLayer,
Translucency: g.Translucency, Translucency: g.Translucency,
SurfaceOpacity: g.SurfaceOpacity,
CullMode: g.CullMode, CullMode: g.CullMode,
FoliageFlags: g.FoliageFlags); FoliageFlags: g.FoliageFlags);
@ -2122,6 +2124,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
g.TextureLayer, g.TextureLayer,
g.Translucency, g.Translucency,
g.FoliageFlags, g.FoliageFlags,
g.SurfaceOpacity,
g.CullMode); g.CullMode);
/// <summary> /// <summary>
@ -2143,6 +2146,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
Translucency = key.Translucency, Translucency = key.Translucency,
CullMode = key.CullMode, CullMode = key.CullMode,
FoliageFlags = key.FoliageFlags, FoliageFlags = key.FoliageFlags,
SurfaceOpacity = key.SurfaceOpacity,
Registration = registration, Registration = registration,
LastUsedFrame = frame, LastUsedFrame = frame,
}; };
@ -2478,7 +2482,8 @@ public sealed partial class WbDrawDispatcher : IDisposable
{ {
byte mask = RetailAlphaMeshRouter.MaskFromTranslucencyKind(kind); byte mask = RetailAlphaMeshRouter.MaskFromTranslucencyKind(kind);
bool detailSurfaceActive = isBuildingShell bool detailSurfaceActive = isBuildingShell
&& RetailDetailTextureContract.ShouldRender(_buildingDetailEnabled(), _buildingDetail); && RetailDetailTextureContract.ShouldRender(_buildingDetailEnabled(), _buildingDetail)
&& _buildingDetail.Tiling != 0f;
RetailAlphaMeshDecision decision = RetailAlphaMeshRouter.Route( RetailAlphaMeshDecision decision = RetailAlphaMeshRouter.Route(
currentlyDrawingSky: false, currentlyDrawingSky: false,
delayMask: RetailAlphaMeshRouter.DefaultDelayMask, delayMask: RetailAlphaMeshRouter.DefaultDelayMask,
@ -2558,11 +2563,12 @@ public sealed partial class WbDrawDispatcher : IDisposable
{ {
// Campaign V slice V2: table slot, not the raw handle. // Campaign V slice V2: table slot, not the raw handle.
TextureIndex = key.TextureSlot.Index, TextureIndex = key.TextureSlot.Index,
SurfaceOpacity = key.SurfaceOpacity,
TextureLayer = key.TextureLayer, TextureLayer = key.TextureLayer,
// DrawMesh invokes RenderMeshSubset with detail enabled for // DrawMesh invokes RenderMeshSubset with detail enabled for
// every built-mesh material subset while curr_detail_surface // every built-mesh material subset while curr_detail_surface
// is installed. The per-instance category still rejects // is installed. The per-instance category still rejects
// ordinary objects in mesh_detail. Review fix round 2 (F2): // ordinary objects in the one-pass fragment arm. Review fix round 2 (F2):
// bit 0 (the #226 built-mesh marker) is unconditional here — // bit 0 (the #226 built-mesh marker) is unconditional here —
// every delayed-alpha entry IS a built-mesh alpha submission // every delayed-alpha entry IS a built-mesh alpha submission
// — but the previous hardcoded `1` silently dropped bits 1/2 // — but the previous hardcoded `1` silently dropped bits 1/2
@ -3431,6 +3437,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
uint TextureIndex, uint TextureIndex,
uint TextureLayer, uint TextureLayer,
TranslucencyKind Translucency, TranslucencyKind Translucency,
float SurfaceOpacity = 1f,
CullMode CullMode = CullMode.CounterClockwise, CullMode CullMode = CullMode.CounterClockwise,
// Campaign VM VM6: FoliageWindClassification.CutoutFoliageFlag / // Campaign VM VM6: FoliageWindClassification.CutoutFoliageFlag /
// TrunkFlag, OR'd into BatchDataPublic.Flags alongside #226's bit 0 // TrunkFlag, OR'd into BatchDataPublic.Flags alongside #226's bit 0
@ -3445,7 +3452,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
public struct BatchDataPublic public struct BatchDataPublic
{ {
public uint TextureIndex; public uint TextureIndex;
public uint Reserved; public float SurfaceOpacity;
public uint TextureLayer; public uint TextureLayer;
public uint Flags; public uint Flags;
} }
@ -3496,7 +3503,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
var bd = new BatchDataPublic var bd = new BatchDataPublic
{ {
TextureIndex = g.TextureIndex, TextureIndex = g.TextureIndex,
Reserved = 0, SurfaceOpacity = g.SurfaceOpacity,
TextureLayer = g.TextureLayer, TextureLayer = g.TextureLayer,
// #226: this is the built-mesh path. Retail DrawMesh passes // #226: this is the built-mesh path. Retail DrawMesh passes
// curr_detail_surface through RenderMeshSubset for opaque, // curr_detail_surface through RenderMeshSubset for opaque,
@ -3668,6 +3675,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned; AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned;
public uint TextureLayer; // Layer in either the pooled composite array or WB shared atlas. public uint TextureLayer; // Layer in either the pooled composite array or WB shared atlas.
public TranslucencyKind Translucency; public TranslucencyKind Translucency;
public float SurfaceOpacity = 1f;
public CullMode CullMode; public CullMode CullMode;
public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes) public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes)
public int InstanceCount; public int InstanceCount;
@ -3717,7 +3725,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
public readonly List<uint> IndoorFlags = new(); public readonly List<uint> IndoorFlags = new();
// #226: 1 for a building-shell instance, 0 otherwise. Parallel to // #226: 1 for a building-shell instance, 0 otherwise. Parallel to
// Matrices and uploaded at storage binding 9 for the detail replay. // Matrices and uploaded at storage binding 9 for one-pass eligibility.
public readonly List<uint> DetailCategories = new(); public readonly List<uint> DetailCategories = new();
// #188: per-instance opacity multiplier, parallel to Matrices. // #188: per-instance opacity multiplier, parallel to Matrices.

View file

@ -32,10 +32,8 @@ namespace AcDream.App.Rendering;
/// ///
/// <para>Scope: WORLD-SPACE geometry only (terrain, EnvCell shells, entity /// <para>Scope: WORLD-SPACE geometry only (terrain, EnvCell shells, entity
/// meshes, particles, portal punches, the directional shadow caster/receiver /// meshes, particles, portal punches, the directional shadow caster/receiver
/// pair). The two <c>RetailDetail</c> pipelines /// pair). Detail-active subsets retain those same base pipeline depth states;
/// (<see cref="RetailDetailTextureContract"/>'s <c>Equal</c>/<c>LessOrEqual</c> /// their material combine occurs inside the original fragment shader.</para>
/// pair, used by the building-detail overlay replay) are a documented
/// exception with their own citation and are untouched by this contract.</para>
/// </summary> /// </summary>
internal static class WorldDepthContract internal static class WorldDepthContract
{ {

View file

@ -65,7 +65,7 @@ internal interface IWorldPassSurface
/// world pass now: <c>portal_depth.vert</c> (the exit seals and punch /// world pass now: <c>portal_depth.vert</c> (the exit seals and punch
/// fans, planes handed per draw through the TerrainClip UBO). S3 review /// fans, planes handed per draw through the TerrainClip UBO). S3 review
/// fix round 1 (F5) deleted the mesh/particle vertex shaders' /// fix round 1 (F5) deleted the mesh/particle vertex shaders'
/// (<c>mesh_modern</c>, <c>mesh_atmospheric</c>, <c>mesh_detail</c>, /// (<c>mesh_modern</c>, <c>mesh_atmospheric</c>,
/// <c>particle</c>, <c>particle_mesh</c>) own per-cell fetch and /// <c>particle</c>, <c>particle_mesh</c>) own per-cell fetch and
/// <c>gl_ClipDistance</c> loop outright: the per-cell clip-region table /// <c>gl_ClipDistance</c> loop outright: the per-cell clip-region table
/// <see cref="PrepareClipFrame"/> used to publish for them had no path /// <see cref="PrepareClipFrame"/> used to publish for them had no path

View file

@ -125,7 +125,7 @@ namespace AcDream.App.UI.Layout;
/// Distance are LIVE through S5-c3's shared building-degrade owner. Landscape /// Distance are LIVE through S5-c3's shared building-degrade owner. Landscape
/// Draw Distance is LIVE through <c>ApplyLandscapeDrawDistance</c>. Building /// Draw Distance is LIVE through <c>ApplyLandscapeDrawDistance</c>. Building
/// Detail Textures is LIVE: #226 consumes it directly in the retail /// Detail Textures is LIVE: #226 consumes it directly in the retail
/// building/EnvCell detail pass; /// one-pass building/EnvCell detail material;
/// Camera Stiffness/Adjustment Speed/Align To Slope/Mouse Look /// Camera Stiffness/Adjustment Speed/Align To Slope/Mouse Look
/// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no /// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no
/// persistent mouse-turning camera mode exists for ANY of these six to /// persistent mouse-turning camera mode exists for ANY of these six to
@ -1294,7 +1294,7 @@ public static class ConfigOptionsPageController
// Every row except Building Detail Textures is still store-only: // Every row except Building Detail Textures is still store-only:
// #226 consumes that existing preference in the retail building and // #226 consumes that existing preference in the retail building and
// EnvCell detail replay. The other rows remain aggregate-preset gaps. // EnvCell one-pass detail material. The other rows remain aggregate-preset gaps.
BuildMenuRow( BuildMenuRow(
listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString, listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString,
read: () => bindings.LoadDisplay().LandscapeTextureDetail, read: () => bindings.LoadDisplay().LandscapeTextureDetail,
@ -1336,7 +1336,7 @@ public static class ConfigOptionsPageController
listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString, listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString,
read: () => bindings.LoadDisplay().BuildingDetailTextures, read: () => bindings.LoadDisplay().BuildingDetailTextures,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { BuildingDetailTextures = value }), apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { BuildingDetailTextures = value }),
storeOnly: false); // LIVE — #226 retail building/EnvCell detail pass storeOnly: false); // LIVE — #226 retail building/EnvCell detail material
BuildToggleRow( BuildToggleRow(
listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString, listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString,

View file

@ -599,6 +599,10 @@ public sealed class MeshExtractor {
Translucency = Translucency =
TranslucencyKindExtensions.FromSurfaceType( TranslucencyKindExtensions.FromSurfaceType(
surface.Type), surface.Type),
SurfaceOpacity =
TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(
surface.Type,
surface.Translucency),
IsTransparent = isTransparent, IsTransparent = isTransparent,
IsAdditive = isAdditive IsAdditive = isAdditive
}; };
@ -1072,6 +1076,10 @@ public sealed class MeshExtractor {
UploadPixelFormat = uploadPixelFormat, UploadPixelFormat = uploadPixelFormat,
UploadPixelType = uploadPixelType, UploadPixelType = uploadPixelType,
Translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type), Translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type),
SurfaceOpacity =
TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(
surface.Type,
surface.Translucency),
IsTransparent = isTransparent, IsTransparent = isTransparent,
IsAdditive = isAdditive, IsAdditive = isAdditive,
}; };

View file

@ -174,6 +174,13 @@ public class TextureBatchData {
public bool IsAdditive { get; set; } public bool IsAdditive { get; set; }
public bool HasWrappingUVs { get; set; } public bool HasWrappingUVs { get; set; }
/// <summary>
/// Retail material alpha derived from the resolved source Surface. This is
/// <c>1 - Surface.Translucency</c> when the Translucent bit is set, and 1
/// otherwise. It is distinct from decoded base-texture alpha.
/// </summary>
public float SurfaceOpacity { get; set; } = 1f;
/// <summary> /// <summary>
/// Retail source surface-array index this subset was constructed from /// Retail source surface-array index this subset was constructed from
/// (contract §3.6) — the CellStruct subset/material OWNER, not the /// (contract §3.6) — the CellStruct subset/material OWNER, not the

View file

@ -228,6 +228,9 @@ public static class ObjectMeshDataSerializer {
w.Write(batch.RetailSurfaceMask); w.Write(batch.RetailSurfaceMask);
w.Write(batch.RawSurfaceType); w.Write(batch.RawSurfaceType);
w.Write(batch.IsCellShell); w.Write(batch.IsCellShell);
// OVERHAUL S5-c4: recipe 9 appends authored material opacity. Recipe
// identity rejects older payloads before this serializer is entered.
w.Write(batch.SurfaceOpacity);
} }
private static TextureBatchData ReadTextureBatchData( private static TextureBatchData ReadTextureBatchData(
@ -252,6 +255,7 @@ public static class ObjectMeshDataSerializer {
batch.RetailSurfaceMask = r.ReadByte(); batch.RetailSurfaceMask = r.ReadByte();
batch.RawSurfaceType = r.ReadUInt32(); batch.RawSurfaceType = r.ReadUInt32();
batch.IsCellShell = r.ReadBoolean(); batch.IsCellShell = r.ReadBoolean();
batch.SurfaceOpacity = r.ReadSingle();
return batch; return batch;
} }

View file

@ -43,10 +43,11 @@ public static class PakFormat {
/// source surface-array index rather than TextureKey/stippling/sides, /// source surface-array index rather than TextureKey/stippling/sides,
/// and built-EnvCell admission is /// and built-EnvCell admission is
/// (Surface.Type &amp; (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 applied after /// (Surface.Type &amp; (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 applied after
/// surface resolution. The binary format remains version 2, but every /// surface resolution. Version 9 appends authored surface opacity to
/// prepared CellStruct/EnvCell render record must be regenerated. /// every prepared mesh texture batch. The binary format remains version
/// 2, but every prepared render record must be regenerated.
/// </summary> /// </summary>
public const uint CurrentBakeToolVersion = 8; public const uint CurrentBakeToolVersion = 9;
} }
/// <summary> /// <summary>

View file

@ -57,6 +57,10 @@ public static class ContentMigrationCatalog
7, 7,
8, 8,
"exact CellStruct surface-index subset construction"), "exact CellStruct surface-index subset construction"),
[9] = FullRebuild(
8,
9,
"authored surface opacity in prepared mesh batches"),
}; };
public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion) public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion)

View file

@ -38,9 +38,10 @@ public sealed class LauncherInstallRecordStore
// Version 7 keeps pak format 2 and regenerates every GfxObj render record // Version 7 keeps pak format 2 and regenerates every GfxObj render record
// with retail's authored DrawingBSP view sphere. Version 8 (OH2/S1) keeps // with retail's authored DrawingBSP view sphere. Version 8 (OH2/S1) keeps
// pak format 2 and regenerates every CellStruct/EnvCell render record // pak format 2 and regenerates every CellStruct/EnvCell render record
// with retail's exact surface-array-index subset construction. Both are // with retail's exact surface-array-index subset construction. Version 9
// mandatory full rebuilds from their predecessor recipe. // carries authored surface opacity in prepared mesh batches. Each is a
public const uint CurrentBakeToolVersion = 8; // mandatory full rebuild from its predecessor recipe.
public const uint CurrentBakeToolVersion = 9;
private static readonly JsonSerializerOptions SerializerOptions = new() private static readonly JsonSerializerOptions SerializerOptions = new()
{ {

View file

@ -90,11 +90,12 @@ public static class RenderPackSpirvValidator
new([0u, 1u], [], true, true, true, false, false), new([0u, 1u], [], true, true, true, false, false),
RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver => RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver =>
new([], [1u, 2u, 3u], true, false, true, false, true), new([], [1u, 2u, 3u], true, false, true, false, true),
// Campaign VM VM6: mesh_atmospheric.vert now reads // Campaign VM VM6: mesh_atmospheric.vert reads the atmospheric
// uAtmosphereClockWind/uAtmosphereWindAmplitude for the same // clock/wind inputs for the same foliage displacement the casters
// foliage-wind displacement the casters apply. // apply. Campaign OVERHAUL S5-c4 adds binding 9 for the per-instance
// detail category used by the retail one-pass material combine.
RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver => RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver =>
new([0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u], [1u], new([0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u], [1u],
true, true, true, false, true), true, true, true, false, true),
_ => new( _ => new(
[], [],

View file

@ -102,7 +102,7 @@ public sealed class DirectionalShadowReceiverTests
Assert.Contains("instanceIndoor[instanceIndex]", vertex, StringComparison.Ordinal); Assert.Contains("instanceIndoor[instanceIndex]", vertex, StringComparison.Ordinal);
Assert.Contains("vSelectionLighting", fragment, StringComparison.Ordinal); Assert.Contains("vSelectionLighting", fragment, StringComparison.Ordinal);
Assert.Contains("vOpacityMultiplier", fragment, StringComparison.Ordinal); Assert.Contains("vOpacityMultiplier", fragment, StringComparison.Ordinal);
Assert.Contains("if (color.a < 0.05) discard", fragment, StringComparison.Ordinal); Assert.Contains("color.a < alphaCutoff", fragment, StringComparison.Ordinal);
Assert.Contains("ACDREAM_SAMPLE_ARRAY", fragment, StringComparison.Ordinal); Assert.Contains("ACDREAM_SAMPLE_ARRAY", fragment, StringComparison.Ordinal);
Assert.Contains("combineOverlays", terrain, StringComparison.Ordinal); Assert.Contains("combineOverlays", terrain, StringComparison.Ordinal);
Assert.Contains("combineRoad", terrain, StringComparison.Ordinal); Assert.Contains("combineRoad", terrain, StringComparison.Ordinal);
@ -115,12 +115,13 @@ public sealed class DirectionalShadowReceiverTests
Assert.Contains("radius = clamp(radius, 0, 2)", receiver, StringComparison.Ordinal); Assert.Contains("radius = clamp(radius, 0, 2)", receiver, StringComparison.Ordinal);
Assert.Contains("float softness = max(uShadowControl.y, 1.0)", receiver, StringComparison.Ordinal); Assert.Contains("float softness = max(uShadowControl.y, 1.0)", receiver, StringComparison.Ordinal);
string detailVertex = File.ReadAllText(Path.Combine( foreach (string worldVertexName in new[] { "mesh_modern.vert", "mesh_atmospheric.vert" })
shaderRoot, {
"mesh_detail.vert")); string worldVertex = File.ReadAllText(Path.Combine(shaderRoot, worldVertexName));
Assert.Contains("int instanceIndex = transformIndex - int(uTextureIndexB)", detailVertex, StringComparison.Ordinal); Assert.Contains("Instances[", worldVertex, StringComparison.Ordinal);
Assert.Contains("Instances[transformIndex].transform", detailVertex, StringComparison.Ordinal); Assert.Contains("].transform", worldVertex, StringComparison.Ordinal);
Assert.Contains("instanceDetailCategory[instanceIndex]", detailVertex, StringComparison.Ordinal); Assert.Contains("instanceDetailCategory[instanceIndex]", worldVertex, StringComparison.Ordinal);
}
} }
private static string RepositoryRoot() private static string RepositoryRoot()

View file

@ -1,6 +1,7 @@
using System.Numerics; using System.Numerics;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk; using AcDream.App.Rendering.Gpu.Vk;
@ -74,34 +75,53 @@ public sealed class EnvCellAlphaDrawSourceTests
DrawPipelineNames(fixture.Device)); DrawPipelineNames(fixture.Device));
} }
[Fact] [Theory]
public void DetailOn_DrawsAtCellTurnWithDetail_AndQueuesNothing() [InlineData(0, "envcell-opaque", 0f)]
[InlineData(1, "envcell-alpha", 0f)]
[InlineData(2, "envcell-additive", 0f)]
[InlineData(3, "envcell-clip", 200f / 255f)]
[InlineData(4, "envcell-clip", 100f / 255f)]
public void DetailOn_EveryEnvCellFamilyDrawsOnceInPlaceWithAuthoredOpacity(
int specIndex,
string expectedPipeline,
float expectedReference)
{ {
BatchSpec spec = specIndex switch
{
0 => BatchSpec.Opaque,
1 => BatchSpec.Alpha,
2 => BatchSpec.Additive,
3 => BatchSpec.ClipDds,
4 => BatchSpec.ClipPaletted,
_ => throw new ArgumentOutOfRangeException(nameof(specIndex)),
};
using var fixture = new ProductionEnvCellFixture( using var fixture = new ProductionEnvCellFixture(
detailSurfaceActive: true, detailSurfaceActive: true,
BatchSpec.ClipDds); spec);
fixture.Queue.BeginFrame(); fixture.Queue.BeginFrame();
fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId); fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId);
Assert.Equal(2, fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().Count()); Assert.Single(fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
Assert.Equal( Assert.Equal(
["envcell-clip", "envcell-retail-detail-clip"], [expectedPipeline],
DrawPipelineNames(fixture.Device)); DrawPipelineNames(fixture.Device));
Assert.All( Assert.All(
DrawPushConstants(fixture.Device), DrawPushConstants(fixture.Device),
constants => Assert.Equal(200f / 255f, constants.ParamB)); constants => Assert.Equal(expectedReference, constants.ParamB));
GpuPipelineDescription detailPipeline = fixture.Device.CreatedPipelines GpuPushConstants armed = Assert.Single(
.Single(static pipeline => DrawPushConstants(fixture.Device),
pipeline.Description.Name == "envcell-retail-detail-clip") constants => constants.ParamA != 0f);
.Description; Assert.NotEqual(0u, armed.TextureIndexA);
Assert.Equal(GpuBlendMode.RetailDetail, detailPipeline.Blend);
Assert.True(detailPipeline.Depth.Test);
Assert.True(detailPipeline.Depth.Write);
Assert.Equal(GpuCompareOp.Equal, detailPipeline.Depth.Compare);
Assert.Equal(0, fixture.Queue.PendingCount); Assert.Equal(0, fixture.Queue.PendingCount);
GpuRecordedStorageBind batchBind = fixture.Device.Calls
.OfType<GpuRecordedStorageBind>()
.Last(call => call.Binding == GpuBindingModel.StorageBatches);
ReadOnlySpan<ModernBatchData> gpuBatches = MemoryMarshal.Cast<byte, ModernBatchData>(
fixture.Device.RingBytes.Slice((int)batchBind.OffsetBytes, (int)batchBind.SizeBytes));
Assert.Equal(0.25f, Assert.Single(gpuBatches.ToArray()).SurfaceOpacity);
fixture.Queue.EndFrame(); fixture.Queue.EndFrame();
Assert.Equal(2, fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().Count()); Assert.Single(fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
} }
/// <summary>Two cell tokens from the SAME <see cref="RetailPViewPassExecutor.EnvCellAlphaDrawSource"/>, /// <summary>Two cell tokens from the SAME <see cref="RetailPViewPassExecutor.EnvCellAlphaDrawSource"/>,
@ -232,28 +252,23 @@ public sealed class EnvCellAlphaDrawSourceTests
string root = RepositoryRoot(); string root = RepositoryRoot();
string modern = File.ReadAllText(Path.Combine( string modern = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_modern.frag")); root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_modern.frag"));
string detail = File.ReadAllText(Path.Combine( string atmospheric = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_detail.frag")); root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_atmospheric.frag"));
string detailVertex = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_detail.vert"));
Assert.Contains("if (color.a < alphaCutoff) discard;", modern, StringComparison.Ordinal); foreach (string shader in new[] { modern, atmospheric })
Assert.DoesNotContain("if (color.a <= alphaCutoff) discard;", modern, StringComparison.Ordinal);
Assert.Contains("isRetailClipReference(uParamB) ? uParamB : 0.05", modern, StringComparison.Ordinal);
Assert.Contains("isRetailClipReference(uParamB) ? uParamB : 0.05", detail, StringComparison.Ordinal);
foreach (string shader in new[] { modern, detail })
{ {
Assert.Contains(
"if (detailActive ? alpha < alphaCutoff : color.a < alphaCutoff)",
shader,
StringComparison.Ordinal);
Assert.DoesNotContain("alpha <= alphaCutoff", shader, StringComparison.Ordinal);
Assert.DoesNotContain("color.a <= alphaCutoff", shader, StringComparison.Ordinal);
Assert.Contains("isRetailClipReference(uParamB) ? uParamB : 0.05", shader, StringComparison.Ordinal);
Assert.Contains("abs(value - (100.0 / 255.0)) < 0.000001", shader, StringComparison.Ordinal); Assert.Contains("abs(value - (100.0 / 255.0)) < 0.000001", shader, StringComparison.Ordinal);
Assert.Contains("abs(value - (200.0 / 255.0)) < 0.000001", shader, StringComparison.Ordinal); Assert.Contains("abs(value - (200.0 / 255.0)) < 0.000001", shader, StringComparison.Ordinal);
Assert.DoesNotContain("uParamB > 0.0", shader, StringComparison.Ordinal); Assert.DoesNotContain("uParamB > 0.0", shader, StringComparison.Ordinal);
Assert.DoesNotContain("value - 0.5", shader, StringComparison.Ordinal); Assert.DoesNotContain("value - 0.5", shader, StringComparison.Ordinal);
} }
Assert.Contains("if (base.a < alphaCutoff)", detail, StringComparison.Ordinal);
Assert.DoesNotContain("if (base.a <= alphaCutoff)", detail, StringComparison.Ordinal);
Assert.Contains(
"vDetailCategory = uParamB == 1.0",
detailVertex,
StringComparison.Ordinal);
} }
[Theory] [Theory]
@ -438,6 +453,12 @@ public sealed class EnvCellAlphaDrawSourceTests
IsTransparent: true, IsTransparent: true,
AcDream.Core.Meshing.TranslucencyKind.AlphaBlend, AcDream.Core.Meshing.TranslucencyKind.AlphaBlend,
PaletteId: 0); PaletteId: 0);
internal static BatchSpec Additive { get; } = new(
RetailAlphaMeshRouter.MaskAlphaFamily,
IsTransparent: true,
AcDream.Core.Meshing.TranslucencyKind.Additive,
PaletteId: 0);
} }
/// <summary> /// <summary>
@ -501,7 +522,9 @@ public sealed class EnvCellAlphaDrawSourceTests
TextureData = new byte[8 * 8 * 4], TextureData = new byte[8 * 8 * 4],
Indices = [0, 1, 2], Indices = [0, 1, 2],
IsTransparent = spec.IsTransparent, IsTransparent = spec.IsTransparent,
IsAdditive = spec.Translucency == AcDream.Core.Meshing.TranslucencyKind.Additive,
Translucency = spec.Translucency, Translucency = spec.Translucency,
SurfaceOpacity = 0.25f,
RetailSurfaceMask = spec.Mask, RetailSurfaceMask = spec.Mask,
CullMode = CullMode.Clockwise, CullMode = CullMode.Clockwise,
IsCellShell = true, IsCellShell = true,

View file

@ -37,9 +37,8 @@ public sealed class GpuContractTests
[Fact] [Fact]
public void StorageBindingsMatchTheShaderSources() public void StorageBindingsMatchTheShaderSources()
{ {
// mesh_modern.vert declares std430 bindings 0..8 in exactly this order; // Both world vertex families declare std430 bindings 0..9 in exactly
// #226's mesh_detail.vert additionally declares binding 9 for the // this order; binding 9 carries per-instance detail eligibility.
// per-instance building category.
Assert.Equal(0u, GpuBindingModel.StorageInstances); Assert.Equal(0u, GpuBindingModel.StorageInstances);
Assert.Equal(1u, GpuBindingModel.StorageBatches); Assert.Equal(1u, GpuBindingModel.StorageBatches);
Assert.Equal(2u, GpuBindingModel.StorageClipRegions); Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
@ -79,15 +78,14 @@ public sealed class GpuContractTests
// WbDrawDispatcher.ApplyRetailBlend selects a blend function from each DAT // WbDrawDispatcher.ApplyRetailBlend selects a blend function from each DAT
// surface's TranslucencyKind. Retail also selects ONE/INVSRCALPHA for a // surface's TranslucencyKind. Retail also selects ONE/INVSRCALPHA for a
// pure Base1ClipMap with override=false (SetSurface @0x0059c72a), while // pure Base1ClipMap with override=false (SetSurface @0x0059c72a), while
// the detail combine is a separate renderer state. Collapsing any of // detail is now computed inside those same pipelines. Collapsing any of
// these modes changes the corresponding subset's composition. // these modes changes the corresponding subset's composition.
Assert.Equal(6, Enum.GetValues<GpuBlendMode>().Length); Assert.Equal(5, Enum.GetValues<GpuBlendMode>().Length);
Assert.Contains(GpuBlendMode.None, Enum.GetValues<GpuBlendMode>()); Assert.Contains(GpuBlendMode.None, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues<GpuBlendMode>()); Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.PremultipliedAlpha, Enum.GetValues<GpuBlendMode>()); Assert.Contains(GpuBlendMode.PremultipliedAlpha, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.Additive, Enum.GetValues<GpuBlendMode>()); Assert.Contains(GpuBlendMode.Additive, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues<GpuBlendMode>()); Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.RetailDetail, Enum.GetValues<GpuBlendMode>());
} }
[Fact] [Fact]

View file

@ -41,23 +41,15 @@ public sealed class VulkanShaderManifestTests
{ {
["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df", ["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df",
["debug_line.vert.spv"] = "f9c6a9b575bb07a426fb6ade677bca96a7752ca6b120e8f6451363ba73b51140", ["debug_line.vert.spv"] = "f9c6a9b575bb07a426fb6ade677bca96a7752ca6b120e8f6451363ba73b51140",
// Re-pinned 2026-09-04 (Campaign OVERHAUL S4-c2 owner-authorized // Re-pinned 2026-09-04 (Campaign OVERHAUL S5-c4): retail's
// repair): Vulkan expresses SetSurface's pure-ClipMap alpha test // single-pass building detail material now runs inside the base
// through uParamB, recognizing only the paletted 100/255 and DDS // fragment. Final alpha is authoredOpacity*liveFade*detail.a^2;
// 200/255 references and using discard-on-less so equality passes. // CLIP tests that value while detail-off retains base-alpha logic.
// The paired binary selects those globals and ["mesh_modern.frag.spv"] = "576af229b96a2a5e9b3b1ff16ab686d96578c2d9d1762b0e225df31bfd7aa86f",
// D3DCMP_GREATEREQUAL at 0x0059c73c..0x0059c838. // S5-c4 replaces the padding word at batch offset 4 with authored
["mesh_modern.frag.spv"] = "1b3a59d0cc7c5bb6acf793d57d9673d860a7eb50bdd51770781f59feac4604a1", // surface opacity and forwards it plus exact detail category/flags
// mesh_modern.vert re-pinned 2026-09-03 (S3 review fix round 1, // flat to the fragment shader. The 16-byte ABI is unchanged.
// F5): the binding=2 ClipRegionBuf (CellClip) SSBO, its ["mesh_modern.vert.spv"] = "1ff1b7118153ba0fddfcdfca39bd973a4b8f6b46db6db772e9886061d15561d8",
// gl_ClipDistance loop, and the gl_PerVertex redeclaration are
// deleted outright — the ONE WbDrawDispatcher API that could
// ever arm per-instance clip routing had ZERO production
// callers, so every instance has always mapped to slot
// 0 (no-clip) in every shipped build; the shader-side clip test
// clipped nothing. binding=3 instanceClipSlot[] stays declared,
// unread (fed 0; the instance-buffer layout is S5's).
["mesh_modern.vert.spv"] = "f9ed4ee7140ccd136130559dbea68545f733f6022f52b085b6df4bcb787223c6",
["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b", ["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b",
// Re-pinned 2026-08-27: portal-view clip slots now travel with // Re-pinned 2026-08-27: portal-view clip slots now travel with
// deferred billboard particles, matching retail PortalList draws. // deferred billboard particles, matching retail PortalList draws.

View file

@ -149,12 +149,6 @@ public sealed class VulkanViewportMappingTests
Assert.Equal( Assert.Equal(
(BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.InverseAlpha)); VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.InverseAlpha));
// VM2 (2026-08-22): real hardware runs the single-pass detail combine,
// a lerp expressed as the ordinary straight-alpha-over factor pair —
// not the two-pass DstColor fallback.
Assert.Equal(
(BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail));
} }
[Fact] [Fact]

View file

@ -113,7 +113,6 @@ public sealed class AtmosphericShaderAbiTests
[InlineData("terrain_atmospheric.vert")] [InlineData("terrain_atmospheric.vert")]
[InlineData("directional_shadow_terrain.vert")] [InlineData("directional_shadow_terrain.vert")]
[InlineData("directional_shadow_terrain_multiview.vert")] [InlineData("directional_shadow_terrain_multiview.vert")]
[InlineData("mesh_detail.vert")]
public void RetailAndTerrainShadersNeverCallAcdreamFoliageDisplace(string fileName) public void RetailAndTerrainShadersNeverCallAcdreamFoliageDisplace(string fileName)
{ {
string text = File.ReadAllText(Path.Combine( string text = File.ReadAllText(Path.Combine(

View file

@ -177,6 +177,23 @@ public sealed class RenderPackSpirvValidatorTests
Assert.True(result.Success, result.Reason); Assert.True(result.Success, result.Reason);
} }
[Fact]
public void WorldDirectionalShadowReceiver_AllowsOnePassDetailCategoryBinding()
{
PipelineVariantDeclaration variant = BuiltInAtmosphericRenderPack.Descriptor
.PipelineVariants.Single(static value =>
value.Semantic
== RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver);
RenderPackSpirvValidationResult result =
RenderPackSpirvValidator.ValidatePipelineVariantShader(
Shader("mesh_atmospheric.vert.spv"),
RenderPackShaderStage.Vertex,
variant);
Assert.True(result.Success, result.Reason);
}
[Fact] [Fact]
public void DirectionalShadowUniformRequiresSixMembersAndSelectedSourceAtOffset320() public void DirectionalShadowUniformRequiresSixMembersAndSelectedSourceAtOffset320()
{ {

View file

@ -1,148 +1,339 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu;
using AcDream.Core.Meshing;
using DatReaderWriter.Enums;
using Xunit;
namespace AcDream.App.Tests.Rendering; namespace AcDream.App.Tests.Rendering;
public sealed class RetailDetailTextureContractTests public sealed class RetailDetailTextureContractTests
{ {
private static readonly TerrainAtlas.RetailDetailTextureBinding Available = new( private static readonly Vector4 Base = new(0.31f, 0.57f, 0.83f, 0.19f);
new GpuTextureSlot(7), private static readonly Vector3 Diffuse = new(0.73f, 0.41f, 0.67f);
Tiling: 4f, private static readonly Vector4 Detail = new(0.91f, 0.23f, 0.49f, 0.62f);
SurfaceTextureId: 0x05001787, private static readonly Vector4 Destination = new(0.17f, 0.37f, 0.71f, 0.29f);
RenderSurfaceId: 0x06006D58,
Width: 256,
Height: 256);
[Fact] public static TheoryData<string, SurfaceType, bool, bool, TranslucencyKind, int, float> PolicyRows()
public void ExistingBuildingDetailSettingIsTheLivePassGate()
{ {
Assert.False(RetailDetailTextureContract.ShouldRender(false, Available)); var rows = new TheoryData<string, SurfaceType, bool, bool, TranslucencyKind, int, float>();
Assert.True(RetailDetailTextureContract.ShouldRender(true, Available)); (SurfaceType Type, bool Paletted, TranslucencyKind Kind,
Assert.False(RetailDetailTextureContract.ShouldRender(true, default)); RetailDetailTextureContract.FramebufferFamily Family, float Reference)[] materials =
} [
(SurfaceType.Base1Image, false, TranslucencyKind.Opaque,
[Fact] RetailDetailTextureContract.FramebufferFamily.Opaque, 0.05f),
public void OpaqueDetailUsesDepthEqualityWhileTransparentDetailDoesNot() (SurfaceType.Base1Image | SurfaceType.Alpha, false, TranslucencyKind.AlphaBlend,
{ RetailDetailTextureContract.FramebufferFamily.Alpha, 0.05f),
Assert.Equal( (SurfaceType.Base1Image | SurfaceType.Alpha | SurfaceType.Additive, false,
GpuCompareOp.Equal, TranslucencyKind.Additive,
RetailDetailTextureContract.DetailDepthCompare(transparent: false)); RetailDetailTextureContract.FramebufferFamily.AlphaAdditive, 0.05f),
Assert.Equal( (SurfaceType.Base1Image | SurfaceType.InvAlpha, false, TranslucencyKind.InvAlpha,
GpuCompareOp.LessOrEqual, RetailDetailTextureContract.FramebufferFamily.InverseAlpha, 0.05f),
RetailDetailTextureContract.DetailDepthCompare(transparent: true)); (SurfaceType.Base1Image | SurfaceType.Base1ClipMap, false, TranslucencyKind.ClipMap,
RetailDetailTextureContract.FramebufferFamily.Clip, 200f / 255f),
(SurfaceType.Base1Image | SurfaceType.Base1ClipMap, true, TranslucencyKind.ClipMap,
RetailDetailTextureContract.FramebufferFamily.Clip, 100f / 255f),
];
foreach (string consumer in new[] { "Building", "EnvCell" })
foreach (var material in materials)
foreach (bool detailEnabled in new[] { false, true })
{
RetailDetailTextureContract.FramebufferFamily family =
consumer == "Building" && material.Kind == TranslucencyKind.ClipMap
? RetailDetailTextureContract.FramebufferFamily.Opaque
: material.Family;
rows.Add(consumer, material.Type, material.Paletted, detailEnabled,
material.Kind, (int)family, material.Reference);
}
return rows;
} }
[Theory] [Theory]
[InlineData(0.1f, 0.2f, 0.3f)] [MemberData(nameof(PolicyRows))]
[InlineData(0.0f, 0.0f, 0.0f)] public void BuildingAndEnvCellPolicyTable_KeepsOneDrawAndOriginalFramebufferFamily(
[InlineData(1.0f, 1.0f, 1.0f)] string consumer,
[InlineData(0.5f, 0.9f, 0.05f)] SurfaceType surfaceType,
public void ZeroDetailAlphaIsExactNoOp(float r, float g, float b) bool paletted,
bool detailEnabled,
TranslucencyKind expectedKind,
int familyValue,
float expectedReference)
{ {
var baseColour = new Vector3(r, g, b); var family = (RetailDetailTextureContract.FramebufferFamily)familyValue;
var transparentDetail = new Vector4(0.9f, 0.1f, 0.7f, 0f); Assert.True(consumer is "Building" or "EnvCell");
Assert.Equal(expectedKind, TranslucencyKindExtensions.FromSurfaceType(surfaceType));
Assert.Equal(["source-subset"], PhysicalDrawSequence(detailEnabled));
PipelineState state = ExpectedPipelineState(
consumer, expectedKind, paletted, expectedReference);
Assert.Equal(expectedReference, state.AlphaReference);
Assert.Equal( Assert.Equal(
baseColour, expectedKind == TranslucencyKind.ClipMap
RetailDetailTextureContract.Expected(baseColour, transparentDetail, opacity: 1f)); ? paletted ? 100f / 255f : 200f / 255f
Assert.True(RetailDetailTextureContract.IsNeutral(transparentDetail, opacity: 1f)); : 0.05f,
} state.AlphaReference);
Assert.True(state.AlphaTest);
[Fact] Assert.Equal(expectedKind is TranslucencyKind.Opaque or TranslucencyKind.ClipMap,
public void ZeroOpacityIsExactNoOp() state.DepthWrite);
{ Assert.Equal(expectedKind switch
var baseColour = new Vector3(0.5f, 0.5f, 0.5f);
var derethCategory = new Vector4(0.165f, 0.165f, 0.165f, 0.132f);
// opacity 0 models a translucency fade that has finished disappearing:
// even a fully-opaque detail texel contributes nothing.
Assert.Equal(
baseColour,
RetailDetailTextureContract.Expected(baseColour, derethCategory, opacity: 0f));
Assert.True(RetailDetailTextureContract.IsNeutral(derethCategory, opacity: 0f));
}
[Fact]
public void LiveDerethCategoryTextureDarkensMidtones()
{
// Live category texture 0x06006D58 (VM2), opaque diffuse (opacity 1).
var detail = new Vector4(0.165f, 0.165f, 0.161f, 0.132f);
var baseColour = new Vector3(0.5f, 0.5f, 0.5f);
Vector3 result = RetailDetailTextureContract.Expected(baseColour, detail, opacity: 1f);
Assert.True(result.X < baseColour.X);
Assert.True(result.Y < baseColour.Y);
Assert.True(result.Z < baseColour.Z);
// lerp(base, detail.rgb, 0.132) ~= 0.868 * base + 0.0218 per channel —
// the mild darkening VM2 measured, not the two-pass fallback's
// brightening. Compared as an absolute difference (not xunit's
// decimal-rounding `precision` parameter, which would flip a channel
// sitting on a rounding boundary) against the stated 1e-3 tolerance.
float approx = 0.868f * baseColour.X + 0.0218f; // same for every channel: baseColour is uniform
Assert.True(MathF.Abs(result.X - approx) < 1e-3f, $"R off by {result.X - approx}");
Assert.True(MathF.Abs(result.Y - approx) < 1e-3f, $"G off by {result.Y - approx}");
Assert.True(MathF.Abs(result.Z - approx) < 1e-3f, $"B off by {result.Z - approx}");
Assert.False(RetailDetailTextureContract.IsNeutral(detail, opacity: 1f));
}
[Fact]
public void FullAlphaReplacesBaseWithDetailColour()
{
var baseColour = new Vector3(0.9f, 0.1f, 0.4f);
var detail = new Vector4(0.2f, 0.3f, 0.4f, 1f);
Vector3 result = RetailDetailTextureContract.Expected(baseColour, detail, opacity: 1f);
// Tolerance rather than bit-exact equality: Lerp's `a + (b - a) * t`
// form is not guaranteed to cancel `a` perfectly at t=1 in floating
// point for arbitrary inputs.
Assert.True(MathF.Abs(result.X - detail.X) < 1e-6f);
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); TranslucencyKind.Opaque => GpuBlendMode.None,
var detail = new Vector4(NextColour(random), random.NextSingle()); TranslucencyKind.AlphaBlend => GpuBlendMode.StraightAlpha,
float opacity = random.NextSingle(); TranslucencyKind.Additive => GpuBlendMode.Additive,
var fog = NextColour(random); TranslucencyKind.InvAlpha => GpuBlendMode.InverseAlpha,
float fogFactor = random.NextSingle(); TranslucencyKind.ClipMap when consumer == "EnvCell" =>
GpuBlendMode.PremultipliedAlpha,
TranslucencyKind.ClipMap => GpuBlendMode.None,
_ => throw new ArgumentOutOfRangeException(nameof(expectedKind)),
}, state.Blend);
Assert.Equal(detailEnabled, DetailIsArmed(detailEnabled));
if (consumer == "Building" && expectedKind == TranslucencyKind.ClipMap)
Assert.Equal("AP-240 immediate A2C", state.Placement);
else
Assert.Equal("source FIFO position", state.Placement);
Vector3 twoDrawOrder = RetailDetailTextureContract.ExpectedFogged( Vector4 expectedSource = detailEnabled
baseColour, detail, opacity, fog, fogFactor); ? IndependentCombine(Base, Diffuse, Detail, 0.75f, 0.4f)
Vector3 retailOrder = Vector3.Lerp( : new Vector4(
RetailDetailTextureContract.Expected(baseColour, detail, opacity), new Vector3(Base.X, Base.Y, Base.Z) * Diffuse,
fog, Base.W * 0.4f);
fogFactor); Vector4 source = detailEnabled
? RetailDetailTextureContract.Combine(Base, Diffuse, Detail, 0.75f, 0.4f)
Assert.True( : expectedSource;
MathF.Abs(twoDrawOrder.X - retailOrder.X) < 1e-6f, AssertVector(expectedSource, source);
$"R: two-draw {twoDrawOrder.X} vs retail {retailOrder.X} (sample {i})"); AssertVector(IndependentComposite(source, Destination, family),
Assert.True( RetailDetailTextureContract.Composite(source, Destination, family));
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) => [Theory]
new(random.NextSingle(), random.NextSingle(), random.NextSingle()); [InlineData(SurfaceType.Translucent, TranslucencyKind.AlphaBlend, false)]
[InlineData(SurfaceType.Translucent | SurfaceType.Base1ClipMap,
TranslucencyKind.AlphaBlend, true)]
[InlineData(SurfaceType.Translucent | SurfaceType.Base1ClipMap | SurfaceType.Alpha,
TranslucencyKind.AlphaBlend, false)]
[InlineData(SurfaceType.Translucent | SurfaceType.Additive,
TranslucencyKind.Additive, false)]
[InlineData(SurfaceType.Translucent | SurfaceType.InvAlpha,
TranslucencyKind.InvAlpha, false)]
public void RawTranslucentOverride_PinsBlendAndClipTestPolicy(
SurfaceType type,
TranslucencyKind expectedKind,
bool rawClipWithoutAlphaFamily)
{
Assert.Equal(expectedKind, TranslucencyKindExtensions.FromSurfaceType(type));
byte mask = RetailAlphaMeshRouter.ConstructSubsetMask(
hasAlphaFamilyBit: (type & (SurfaceType.Alpha | SurfaceType.InvAlpha | SurfaceType.Additive)) != 0,
hasClipMapBit: (type & SurfaceType.Base1ClipMap) != 0,
hasTranslucentBit: true,
hasPositiveStippling: false);
Assert.Equal(rawClipWithoutAlphaFamily, (mask & RetailAlphaMeshRouter.MaskClipMap) != 0);
if ((type & SurfaceType.Translucent) != 0 && (type & SurfaceType.Base1ClipMap) != 0)
Assert.False(expectedKind == TranslucencyKind.ClipMap);
}
[Fact]
public void CombinePinsSquaredFinalAlphaAndExcludesBaseAlpha()
{
Vector4 actual = RetailDetailTextureContract.Combine(
Base, Diffuse, Detail, authoredOpacity: 0.75f, liveOpacity: 0.4f);
float a = 0.75f * 0.4f;
float w = a * Detail.W;
Vector3 expectedRgb = new(Detail.X, Detail.Y, Detail.Z);
expectedRgb = expectedRgb * w
+ new Vector3(Base.X, Base.Y, Base.Z) * Diffuse * (1f - w);
float expectedAlpha = a * Detail.W * Detail.W;
AssertVector(new Vector4(expectedRgb, expectedAlpha), actual);
Assert.NotEqual(a * Detail.W, actual.W);
Assert.NotEqual(Base.W * a * Detail.W * Detail.W, actual.W);
Vector3 oldTwoDrawRgb = new Vector3(Base.X, Base.Y, Base.Z) * Diffuse
* (new Vector3(Detail.X, Detail.Y, Detail.Z)
+ Vector3.One - new Vector3(Detail.W));
Assert.NotEqual(oldTwoDrawRgb.X, actual.X);
Assert.NotEqual(oldTwoDrawRgb.Y, actual.Y);
Assert.NotEqual(oldTwoDrawRgb.Z, actual.Z);
Vector4 changedBaseAlpha = new(Base.X, Base.Y, Base.Z, 0.97f);
AssertVector(actual, RetailDetailTextureContract.Combine(
changedBaseAlpha, Diffuse, Detail, 0.75f, 0.4f));
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
public void EveryFramebufferEquationUsesFinalX(int familyValue)
{
var family = (RetailDetailTextureContract.FramebufferFamily)familyValue;
Vector4 source = RetailDetailTextureContract.Combine(
Base, Diffuse, Detail, 0.75f, 0.4f);
float x = source.W;
Vector4 expected = family switch
{
RetailDetailTextureContract.FramebufferFamily.Opaque => source,
RetailDetailTextureContract.FramebufferFamily.Alpha =>
source * x + Destination * (1f - x),
RetailDetailTextureContract.FramebufferFamily.AlphaAdditive =>
source * x + Destination,
RetailDetailTextureContract.FramebufferFamily.Additive =>
source + Destination,
RetailDetailTextureContract.FramebufferFamily.InverseAlpha =>
source * (1f - x) + Destination * x,
RetailDetailTextureContract.FramebufferFamily.InverseAlphaAdditive =>
source * (1f - x) + Destination,
RetailDetailTextureContract.FramebufferFamily.Clip =>
source + Destination * new Vector4(1f - x),
_ => throw new ArgumentOutOfRangeException(nameof(family)),
};
AssertVector(expected, RetailDetailTextureContract.Composite(source, Destination, family));
}
[Theory]
[InlineData(100f / 255f)]
[InlineData(200f / 255f)]
public void ClipBoundaryIsFinalAlphaGreaterEqual(float reference)
{
Assert.False(RetailDetailTextureContract.SurvivesClip(
MathF.BitDecrement(reference), reference));
Assert.True(RetailDetailTextureContract.SurvivesClip(reference, reference));
Assert.True(RetailDetailTextureContract.SurvivesClip(
MathF.BitIncrement(reference), reference));
}
[Fact]
public void FogRunsAfterCombineAndLeavesAlphaUntouched()
{
Vector4 source = RetailDetailTextureContract.Combine(
Base, Diffuse, Detail, 0.5f, 0.65f);
Vector3 fog = new(0.13f, 0.29f, 0.47f);
Vector4 fogged = RetailDetailTextureContract.ApplyFog(source, fog, 0.38f);
AssertVector(new Vector4(Vector3.Lerp(
new Vector3(source.X, source.Y, source.Z), fog, 0.38f), source.W), fogged);
}
[Fact]
public void BothShaderFamiliesUseTheSharedOnePassSourceAndDebugPrecedesDetailSample()
{
string ordinary = Shader("mesh_modern.frag");
string atmospheric = Shader("mesh_atmospheric.frag");
string shared = Shader("retail_detail_material.glsl");
Assert.Contains("#include \"retail_detail_material.glsl\"", ordinary, StringComparison.Ordinal);
Assert.Contains("#include \"retail_detail_material.glsl\"", atmospheric, StringComparison.Ordinal);
Assert.Contains("materialAlpha * detail.a * detail.a", shared, StringComparison.Ordinal);
Assert.DoesNotContain("baseTexel.a", shared, StringComparison.Ordinal);
foreach (string shader in new[] { ordinary, atmospheric })
{
Assert.Contains("if (detailActive)", shader, StringComparison.Ordinal);
Assert.True(shader.IndexOf("if (detailActive)", StringComparison.Ordinal)
< shader.IndexOf("vec4 detail =", StringComparison.Ordinal));
Assert.Contains("vSurfaceOpacity * vOpacityMultiplier", shader, StringComparison.Ordinal);
}
Assert.True(ordinary.IndexOf("if (uLightDebug == 3)", StringComparison.Ordinal)
< ordinary.IndexOf("vec4 detail =", StringComparison.Ordinal));
Assert.True(atmospheric.IndexOf("if (uLightDebug == 3)", StringComparison.Ordinal)
< atmospheric.IndexOf("vec4 detail =", StringComparison.Ordinal));
}
private static string Shader(string file) => File.ReadAllText(Path.Combine(
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", file));
private static string RepositoryRoot() => Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
private static void AssertVector(Vector4 expected, Vector4 actual)
{
Assert.Equal(expected.X, actual.X, 6);
Assert.Equal(expected.Y, actual.Y, 6);
Assert.Equal(expected.Z, actual.Z, 6);
Assert.Equal(expected.W, actual.W, 6);
}
private static string[] PhysicalDrawSequence(bool detailEnabled)
{
_ = detailEnabled;
return ["source-subset"];
}
private static bool DetailIsArmed(bool detailEnabled) => detailEnabled;
private static PipelineState ExpectedPipelineState(
string consumer,
TranslucencyKind kind,
bool paletted,
float reference)
{
_ = paletted;
GpuBlendMode blend = kind switch
{
TranslucencyKind.Opaque => GpuBlendMode.None,
TranslucencyKind.AlphaBlend => GpuBlendMode.StraightAlpha,
TranslucencyKind.Additive => GpuBlendMode.Additive,
TranslucencyKind.InvAlpha => GpuBlendMode.InverseAlpha,
TranslucencyKind.ClipMap when consumer == "EnvCell" =>
GpuBlendMode.PremultipliedAlpha,
TranslucencyKind.ClipMap => GpuBlendMode.None,
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
return new PipelineState(
blend,
kind is TranslucencyKind.Opaque or TranslucencyKind.ClipMap,
AlphaTest: true,
reference,
consumer == "Building" && kind == TranslucencyKind.ClipMap
? "AP-240 immediate A2C"
: "source FIFO position");
}
private static Vector4 IndependentCombine(
Vector4 baseTexel,
Vector3 diffuse,
Vector4 detail,
float authoredOpacity,
float liveOpacity)
{
float a = authoredOpacity * liveOpacity;
float w = a * detail.W;
Vector3 rgb = new(detail.X, detail.Y, detail.Z);
rgb = rgb * w
+ new Vector3(baseTexel.X, baseTexel.Y, baseTexel.Z)
* diffuse * (1f - w);
return new Vector4(rgb, a * detail.W * detail.W);
}
private readonly record struct PipelineState(
GpuBlendMode Blend,
bool DepthWrite,
bool AlphaTest,
float AlphaReference,
string Placement);
private static Vector4 IndependentComposite(
Vector4 source,
Vector4 destination,
RetailDetailTextureContract.FramebufferFamily family)
{
float x = source.W;
return family switch
{
RetailDetailTextureContract.FramebufferFamily.Opaque => source,
RetailDetailTextureContract.FramebufferFamily.Alpha =>
source * x + destination * (1f - x),
RetailDetailTextureContract.FramebufferFamily.AlphaAdditive =>
source * x + destination,
RetailDetailTextureContract.FramebufferFamily.Additive =>
source + destination,
RetailDetailTextureContract.FramebufferFamily.InverseAlpha =>
source * (1f - x) + destination * x,
RetailDetailTextureContract.FramebufferFamily.InverseAlphaAdditive =>
source * (1f - x) + destination,
RetailDetailTextureContract.FramebufferFamily.Clip =>
source + destination * new Vector4(1f - x),
_ => throw new ArgumentOutOfRangeException(nameof(family)),
};
}
} }

View file

@ -23,7 +23,7 @@ namespace AcDream.App.Tests.Rendering;
/// .TryCreateDetailTexture</c> (~TerrainAtlas.cs:488-556) must upload a FULL /// .TryCreateDetailTexture</c> (~TerrainAtlas.cs:488-556) must upload a FULL
/// mip chain, and it must be sampled with the repeat/linear world sampler, /// 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 /// or the "attenuation is the sampler's linear mip chain" claim in
/// mesh_detail.vert's header comment and the VM1 commit is unverified. /// the one-pass world material contract and the VM1 commit is unverified.
/// Drives the private method directly (reflection, same pattern as /// Drives the private method directly (reflection, same pattern as
/// EnvCellRendererTests' private-method tests) against a synthetic /// EnvCellRendererTests' private-method tests) against a synthetic
/// PFID_A8R8G8B8 RenderSurface, so no installed DAT is required — this lane /// PFID_A8R8G8B8 RenderSurface, so no installed DAT is required — this lane

View file

@ -285,19 +285,38 @@ public sealed class OrderPreservingSubmitterTests
} }
[Fact] [Fact]
public void PrepareThenDraw_ADetailCategoryCommandRecordsItsOwnSoloDraw() public void PrepareThenDraw_OrdinaryBuildingClipBuildingOrdinary_ArmsOnePassInPlace()
{ {
using var fx = new DispatcherFixture(); using var fx = new DispatcherFixture(detailAvailable: true, detailEnabled: true);
using DrawScope draw = fx.BeginDraw(); using DrawScope draw = fx.BeginDraw();
OrderedDrawStream stream = StreamOf( OrderedDrawStream stream = StreamOf(
MakeCommand(0), MakeCommand(0),
MakeCommand(1, detailCategory: 1), MakeCommand(1, detailCategory: 1),
MakeCommand(2)); MakeCommand(2, translucency: TranslucencyKind.ClipMap, detailCategory: 1),
MakeCommand(3));
PrepareAndDrawWhole(fx.Dispatcher, draw, stream); PrepareAndDrawWhole(fx.Dispatcher, draw, stream);
Assert.Equal([(0, 1), (1, 1), (2, 1)], DecodeDrawRanges(fx.Device)); List<(GpuPushConstants Constants, int Start, int Count)> runs = DecodeRuns(fx.Device);
Assert.Equal([(0, 1), (1, 1), (2, 1), (3, 1)],
runs.Select(run => (run.Start, run.Count)).ToList());
Assert.Equal((0u, 0f), (runs[0].Constants.TextureIndexA, runs[0].Constants.ParamA));
Assert.Equal((77u, 3.5f), (runs[1].Constants.TextureIndexA, runs[1].Constants.ParamA));
Assert.Equal((77u, 3.5f), (runs[2].Constants.TextureIndexA, runs[2].Constants.ParamA));
Assert.Equal((0u, 0f), (runs[3].Constants.TextureIndexA, runs[3].Constants.ParamA));
Assert.Equal(
[
"wb-mesh-opaque-1x",
"wb-mesh-opaque-a2c-1x",
"wb-mesh-opaque-a2c-1x",
"wb-mesh-opaque-a2c-1x",
"wb-mesh-opaque-a2c-1x",
],
fx.Device.Calls.OfType<GpuRecordedPipelineBind>()
.Select(call => call.PipelineName)
.ToArray());
Assert.Equal(4, fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().Count());
} }
[Fact] [Fact]
@ -590,7 +609,7 @@ public sealed class OrderPreservingSubmitterTests
private readonly WbMeshAdapter _meshAdapter; private readonly WbMeshAdapter _meshAdapter;
private readonly TextureCache _textures; private readonly TextureCache _textures;
public DispatcherFixture() public DispatcherFixture(bool detailAvailable = false, bool detailEnabled = false)
{ {
Device = new RecordingGpuDevice(); Device = new RecordingGpuDevice();
FrameLifetime = new GpuDeviceFrameLifetime(Device); FrameLifetime = new GpuDeviceFrameLifetime(Device);
@ -615,7 +634,12 @@ public sealed class OrderPreservingSubmitterTests
_meshAdapter, _meshAdapter,
entitySpawnAdapter, entitySpawnAdapter,
new EntityClassificationCache(), new EntityClassificationCache(),
new AcDream.Core.Rendering.TranslucencyFadeManager()); new AcDream.Core.Rendering.TranslucencyFadeManager(),
buildingDetail: detailAvailable
? new TerrainAtlas.RetailDetailTextureBinding(
new GpuTextureSlot(77), 3.5f, 0x05000001, 0x08000001, 16, 16)
: default,
buildingDetailEnabled: () => detailEnabled);
} }
public RecordingGpuDevice Device { get; } public RecordingGpuDevice Device { get; }

View file

@ -316,7 +316,7 @@ public sealed class WalkStaticStreamPopulatorTests
Assert.DoesNotContain( Assert.DoesNotContain(
"one stable far-to-near stream keyed", architecture, StringComparison.OrdinalIgnoreCase); "one stable far-to-near stream keyed", architecture, StringComparison.OrdinalIgnoreCase);
Assert.Contains("161 active rows", register, StringComparison.Ordinal); Assert.Contains("160 active rows", register, StringComparison.Ordinal);
int apSectionStart = register.IndexOf( int apSectionStart = register.IndexOf(
"## 3. Documented approximation (AP)", StringComparison.Ordinal); "## 3. Documented approximation (AP)", StringComparison.Ordinal);
int apSectionEnd = register.IndexOf( int apSectionEnd = register.IndexOf(
@ -338,7 +338,7 @@ public sealed class WalkStaticStreamPopulatorTests
Assert.Single(File.ReadLines(registerPath), static line => Assert.Single(File.ReadLines(registerPath), static line =>
line.StartsWith("| AP-243 |", StringComparison.Ordinal)); line.StartsWith("| AP-243 |", StringComparison.Ordinal));
Assert.Equal( Assert.Equal(
161, 160,
File.ReadLines(registerPath).Count(static line => File.ReadLines(registerPath).Count(static line =>
line.StartsWith("| AP-", StringComparison.Ordinal))); line.StartsWith("| AP-", StringComparison.Ordinal)));
Assert.Contains("greater than 50 m", register, StringComparison.Ordinal); Assert.Contains("greater than 50 m", register, StringComparison.Ordinal);
@ -1120,9 +1120,9 @@ public sealed class WalkStaticStreamPopulatorTests
/// <summary> /// <summary>
/// S4-c2 final M3 production pin for <c>SubmitWalkAlphaInstance</c>: /// S4-c2 final M3 production pin for <c>SubmitWalkAlphaInstance</c>:
/// building shell + detail ON executes the immediate base+detail draws at /// building shell + detail ON executes one armed original-pipeline draw at
/// the submit mark and queues nothing; detail OFF queues, then drains the /// the submit mark and queues nothing; detail OFF queues, then drains the
/// base draw without a detail pass; an ordinary batch remains deferred /// neutral base draw; an ordinary batch remains deferred
/// even while the global detail option is on. Mutation check: hardcoding /// even while the global detail option is on. Mutation check: hardcoding
/// <c>detailSurfaceActive=false</c> at the production submit makes the /// <c>detailSurfaceActive=false</c> at the production submit makes the
/// first case's pending-count assertion fail with 1.</summary> /// first case's pending-count assertion fail with 1.</summary>
@ -1145,9 +1145,11 @@ public sealed class WalkStaticStreamPopulatorTests
on.Device.Calls.OfType<GpuRecordedPipelineBind>(), on.Device.Calls.OfType<GpuRecordedPipelineBind>(),
call => call.PipelineName.Contains("alpha", StringComparison.Ordinal) call => call.PipelineName.Contains("alpha", StringComparison.Ordinal)
&& !call.PipelineName.Contains("detail", StringComparison.Ordinal)); && !call.PipelineName.Contains("detail", StringComparison.Ordinal));
Assert.Contains( Assert.Single(on.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
on.Device.Calls.OfType<GpuRecordedPipelineBind>(), Assert.DoesNotContain(on.Device.Calls.OfType<GpuRecordedPipelineBind>(),
call => call.PipelineName.Contains("retail-detail-alpha", StringComparison.Ordinal)); call => call.PipelineName.Contains("detail", StringComparison.Ordinal));
Assert.Contains(on.Device.Calls.OfType<GpuRecordedPushConstants>(),
call => call.Constants.TextureIndexA == 99u && call.Constants.ParamA == 2f);
} }
using (var off = new DispatcherFixture( using (var off = new DispatcherFixture(
@ -1164,9 +1166,8 @@ public sealed class WalkStaticStreamPopulatorTests
Assert.Equal(1, off.AlphaQueue!.PendingCount); Assert.Equal(1, off.AlphaQueue!.PendingCount);
Assert.Empty(off.Device.Calls.OfType<GpuRecordedPipelineBind>()); Assert.Empty(off.Device.Calls.OfType<GpuRecordedPipelineBind>());
off.AlphaQueue.EndFrame(); off.AlphaQueue.EndFrame();
Assert.DoesNotContain( Assert.DoesNotContain(off.Device.Calls.OfType<GpuRecordedPushConstants>(),
off.Device.Calls.OfType<GpuRecordedPipelineBind>(), call => call.Constants.TextureIndexA == 99u || call.Constants.ParamA != 0f);
call => call.PipelineName.Contains("retail-detail", StringComparison.Ordinal));
} }
using (var ordinary = new DispatcherFixture( using (var ordinary = new DispatcherFixture(
@ -1212,12 +1213,41 @@ public sealed class WalkStaticStreamPopulatorTests
defer.Invoke(fx.Dispatcher, [Matrix4x4.Identity]); defer.Invoke(fx.Dispatcher, [Matrix4x4.Identity]);
Assert.Equal(0, fx.AlphaQueue!.PendingCount); Assert.Equal(0, fx.AlphaQueue!.PendingCount);
Assert.Contains( Assert.Single(fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
fx.Device.Calls.OfType<GpuRecordedPipelineBind>(), Assert.DoesNotContain(fx.Device.Calls.OfType<GpuRecordedPipelineBind>(),
call => call.PipelineName.Contains("retail-detail-alpha", StringComparison.Ordinal)); call => call.PipelineName.Contains("detail", StringComparison.Ordinal));
Assert.Contains(fx.Device.Calls.OfType<GpuRecordedPushConstants>(),
call => call.Constants.TextureIndexA == 99u && call.Constants.ParamA == 2f);
} }
private static WbDrawDispatcher.WalkClassifiedBatch AlphaWalkBatch(uint detailCategory) => [Theory]
[InlineData(TranslucencyKind.AlphaBlend, "wb-mesh-alpha-1x")]
[InlineData(TranslucencyKind.Additive, "wb-mesh-additive-1x")]
[InlineData(TranslucencyKind.InvAlpha, "wb-mesh-inverse-1x")]
public void ImmediateBuildingDetail_RetainsOriginalFramebufferFamily(
TranslucencyKind kind,
string expectedPipeline)
{
using var fx = new DispatcherFixture(
withAlphaQueue: true,
detailAvailable: true,
detailEnabled: true);
using DrawScope draw = fx.BeginDraw(beginAlpha: true);
WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch(
detailCategory: 1u,
translucency: kind);
fx.Dispatcher.SubmitWalkAlphaInstance(in building, Matrix4x4.Identity);
Assert.Equal(0, fx.AlphaQueue!.PendingCount);
Assert.Single(fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
Assert.Contains(fx.Device.Calls.OfType<GpuRecordedPipelineBind>(),
call => call.PipelineName == expectedPipeline);
}
private static WbDrawDispatcher.WalkClassifiedBatch AlphaWalkBatch(
uint detailCategory,
TranslucencyKind translucency = TranslucencyKind.AlphaBlend) =>
new( new(
new GroupKey( new GroupKey(
0, 0,
@ -1225,7 +1255,7 @@ public sealed class WalkStaticStreamPopulatorTests
3, 3,
new GpuTextureSlot(1), new GpuTextureSlot(1),
0, 0,
TranslucencyKind.AlphaBlend, translucency,
FoliageFlags: 0), FoliageFlags: 0),
Matrix4x4.Identity, Matrix4x4.Identity,
ClipSlot: 0, ClipSlot: 0,

View file

@ -101,16 +101,16 @@ public class EnvCellRendererTests
Vector4.Zero, Vector4.Zero,
sampleCount: 1)); sampleCount: 1));
EnvCellRenderer.BindEnvironmentDetailCategory(pass, frame); using EnvCellRenderer renderer = CreateRenderer();
renderer.BindEnvironmentDetailCategory(pass, frame, instanceCount: 3);
GpuRecordedStorageBind storageBind = Assert.Single( GpuRecordedStorageBind storageBind = Assert.Single(
device.Calls.OfType<GpuRecordedStorageBind>()); device.Calls.OfType<GpuRecordedStorageBind>());
Assert.Equal(GpuBindingModel.StorageInstanceDetailCategory, storageBind.Binding); Assert.Equal(GpuBindingModel.StorageInstanceDetailCategory, storageBind.Binding);
Assert.Equal((uint)sizeof(uint), storageBind.SizeBytes); Assert.Equal((uint)(3 * sizeof(uint)), storageBind.SizeBytes);
Assert.Equal( ReadOnlySpan<uint> categories = MemoryMarshal.Cast<byte, uint>(
1u, device.RingBytes.Slice((int)storageBind.OffsetBytes, (int)storageBind.SizeBytes));
MemoryMarshal.Read<uint>( Assert.Equal([1u, 1u, 1u], categories.ToArray());
device.RingBytes.Slice((int)storageBind.OffsetBytes, sizeof(uint))));
} }
/// <summary> /// <summary>
@ -118,17 +118,14 @@ public class EnvCellRendererTests
/// StorageInstances/StorageBatches/StorageClipSlots/StorageGlobalLights/ /// StorageInstances/StorageBatches/StorageClipSlots/StorageGlobalLights/
/// StorageInstanceLightSets every frame but never /// StorageInstanceLightSets every frame but never
/// GpuBindingModel.StorageInstanceAlpha (binding 7) — the SSBO both /// GpuBindingModel.StorageInstanceAlpha (binding 7) — the SSBO both
/// mesh_modern.vert (interior shells) and mesh_detail.vert (interior /// both world vertex families read as instanceAlpha[instanceIndex].
/// detail replay) read as instanceAlpha[instanceIndex]. Predates VM1 /// Predates VM1 (6c79d35c has the same omission); an unfixed gap here
/// (6c79d35c has the same omission); with mesh_detail.vert now reading /// would have made the interior detail material read whatever section a DIFFERENT renderer (or a
/// that binding too, an unfixed gap here would have made the interior
/// detail overlay read whatever section a DIFFERENT renderer (or a
/// stale ring slot) last left in binding 7, indexed by these cells' /// stale ring slot) last left in binding 7, indexed by these cells'
/// instance ids. This pins that SubmitRhi binds its own constant-1.0f /// instance ids. This pins that SubmitRhi binds its own constant-1.0f
/// section, sized to the live instance count, before it records any /// section, sized to the live instance count, before it records any
/// draw in the pass — one bind serves every subsequent draw in that /// draw in the pass — one bind serves every subsequent draw in that
/// pass, mesh_modern's shell pipeline and (when enabled) mesh_detail's /// pass and every shell pipeline.
/// replay alike.
/// </summary> /// </summary>
[Theory] [Theory]
[InlineData(WbRenderPass.Opaque)] [InlineData(WbRenderPass.Opaque)]
@ -242,7 +239,7 @@ public class EnvCellRendererTests
} }
[Fact] [Fact]
public void RetailDetailPipelinesPreserveOpaqueAndTransparentDepthWriteContracts() public void DetailUsesOnlyTheOriginalFourShellPipelines()
{ {
using var device = new RecordingGpuDevice(); using var device = new RecordingGpuDevice();
using var meshManager = CreateMeshManager(device); using var meshManager = CreateMeshManager(device);
@ -253,23 +250,9 @@ public class EnvCellRendererTests
meshManager, meshManager,
new WbFrustum()); new WbFrustum());
GpuPipelineDescription opaqueDetail = Assert.Single( Assert.Equal(4, device.CreatedPipelines.Count);
device.CreatedPipelines, Assert.DoesNotContain(device.CreatedPipelines,
pipeline => pipeline.Description.Name == "envcell-retail-detail") pipeline => pipeline.Description.Name.Contains("detail", StringComparison.Ordinal));
.Description;
GpuPipelineDescription transparentDetail = Assert.Single(
device.CreatedPipelines,
pipeline => pipeline.Description.Name == "envcell-retail-detail-alpha")
.Description;
Assert.Equal(GpuBlendMode.RetailDetail, opaqueDetail.Blend);
Assert.True(opaqueDetail.Depth.Write);
Assert.Equal(GpuCompareOp.Equal, opaqueDetail.Depth.Compare);
Assert.False(opaqueDetail.AlphaToCoverage);
Assert.Equal(GpuBlendMode.RetailDetail, transparentDetail.Blend);
Assert.False(transparentDetail.Depth.Write);
Assert.Equal(GpuCompareOp.LessOrEqual, transparentDetail.Depth.Compare);
Assert.False(transparentDetail.AlphaToCoverage);
} }
[Fact] [Fact]

View file

@ -20,7 +20,7 @@ namespace AcDream.App.Tests.Rendering.Wb;
/// ///
/// Layout under test (std430, 16 bytes total): /// Layout under test (std430, 16 bytes total):
/// offset 0 : uint TextureTableIndex /// offset 0 : uint TextureTableIndex
/// offset 4 : uint Reserved (pad) /// offset 4 : float SurfaceOpacity
/// offset 8 : uint TextureIndex (layer within the texture array) /// offset 8 : uint TextureIndex (layer within the texture array)
/// offset 12 : uint Flags /// offset 12 : uint Flags
/// </summary> /// </summary>
@ -36,7 +36,7 @@ public class ModernBatchDataLayoutTests
public void FieldOffsets_MatchStd430Layout() public void FieldOffsets_MatchStd430Layout()
{ {
Assert.Equal(0, (int)Marshal.OffsetOf<ModernBatchData>(nameof(ModernBatchData.TextureTableIndex))); Assert.Equal(0, (int)Marshal.OffsetOf<ModernBatchData>(nameof(ModernBatchData.TextureTableIndex)));
Assert.Equal(4, (int)Marshal.OffsetOf<ModernBatchData>(nameof(ModernBatchData.Reserved))); Assert.Equal(4, (int)Marshal.OffsetOf<ModernBatchData>(nameof(ModernBatchData.SurfaceOpacity)));
Assert.Equal(8, (int)Marshal.OffsetOf<ModernBatchData>(nameof(ModernBatchData.TextureIndex))); Assert.Equal(8, (int)Marshal.OffsetOf<ModernBatchData>(nameof(ModernBatchData.TextureIndex)));
Assert.Equal(12, (int)Marshal.OffsetOf<ModernBatchData>(nameof(ModernBatchData.Flags))); Assert.Equal(12, (int)Marshal.OffsetOf<ModernBatchData>(nameof(ModernBatchData.Flags)));
} }
@ -47,11 +47,12 @@ public class ModernBatchDataLayoutTests
var data = new ModernBatchData var data = new ModernBatchData
{ {
TextureTableIndex = 7u, TextureTableIndex = 7u,
SurfaceOpacity = 0.25f,
TextureIndex = 3u, TextureIndex = 3u,
}; };
Assert.Equal(7u, data.TextureTableIndex); Assert.Equal(7u, data.TextureTableIndex);
Assert.Equal(0u, data.Reserved); Assert.Equal(0.25f, data.SurfaceOpacity);
Assert.Equal(3u, data.TextureIndex); Assert.Equal(3u, data.TextureIndex);
Assert.Equal(0u, data.Flags); Assert.Equal(0u, data.Flags);
} }

View file

@ -207,7 +207,7 @@ public sealed class MeshExtractorSolidFaceExtractionTests
var dats = new FakeMeshExtractorDats(); var dats = new FakeMeshExtractorDats();
RegisterTexturedQuad( RegisterTexturedQuad(
dats, dats,
SurfaceType.Base1Image, SurfaceType.Base1Image | SurfaceType.Translucent,
PixelFormat.PFID_DXT1, PixelFormat.PFID_DXT1,
new byte[8], new byte[8],
translucency: 0.25f); translucency: 0.25f);
@ -219,7 +219,40 @@ public sealed class MeshExtractorSolidFaceExtractionTests
KeyValuePair<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> group = KeyValuePair<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> group =
Assert.Single(mesh.TextureBatches); Assert.Single(mesh.TextureBatches);
Assert.Equal(TextureFormat.RGBA8, group.Key.Format); Assert.Equal(TextureFormat.RGBA8, group.Key.Format);
Assert.Equal(4 * 4 * 4, Assert.Single(group.Value).TextureData.Length); TextureBatchData batch = Assert.Single(group.Value);
Assert.Equal(4 * 4 * 4, batch.TextureData.Length);
Assert.Equal(0.75f, batch.SurfaceOpacity);
}
[Theory]
[InlineData(0f, 1f)]
[InlineData(0.25f, 0.75f)]
[InlineData(0.5f, 0.5f)]
[InlineData(0.75f, 0.25f)]
[InlineData(1f, 0f)]
public void PrepareCellStructMeshData_CarriesAuthoredSurfaceOpacity(
float translucency,
float expectedOpacity)
{
var dats = new FakeMeshExtractorDats();
RegisterCellTexturedSurface(
dats,
SurfaceType.Base1Image | SurfaceType.Translucent,
translucency);
var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null);
ObjectMeshData mesh = Assert.IsType<ObjectMeshData>(
extractor.PrepareCellStructMeshData(
id: 1,
BuildQuadCellStruct(RetailCullMode.Landblock),
surfaceOverrides: [2],
Matrix4x4.Identity,
CancellationToken.None));
TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value);
Assert.Equal(
BitConverter.SingleToInt32Bits(expectedOpacity),
BitConverter.SingleToInt32Bits(batch.SurfaceOpacity));
} }
/// <summary> /// <summary>
@ -232,12 +265,16 @@ public sealed class MeshExtractorSolidFaceExtractionTests
/// not change what these tests actually pin (verified by hand against /// not change what these tests actually pin (verified by hand against
/// CellStructSideCandidates.TriangleFanIndices before this change). /// CellStructSideCandidates.TriangleFanIndices before this change).
/// </summary> /// </summary>
private static void RegisterCellTexturedSurface(FakeMeshExtractorDats dats) private static void RegisterCellTexturedSurface(
FakeMeshExtractorDats dats,
SurfaceType type = SurfaceType.Base1Image,
float translucency = 0f)
{ {
dats.Register(TexturedSurfaceId, new Surface dats.Register(TexturedSurfaceId, new Surface
{ {
Type = SurfaceType.Base1Image, Type = type,
OrigTextureId = SurfaceTextureId, OrigTextureId = SurfaceTextureId,
Translucency = translucency,
}); });
dats.Register(SurfaceTextureId, new SurfaceTexture dats.Register(SurfaceTextureId, new SurfaceTexture
{ {

View file

@ -103,6 +103,9 @@ public static class ObjectMeshDataEquality {
Assert.True(expected.SourceSurfaceIndex == actual.SourceSurfaceIndex, $"{path}.SourceSurfaceIndex: expected {expected.SourceSurfaceIndex}, got {actual.SourceSurfaceIndex}"); Assert.True(expected.SourceSurfaceIndex == actual.SourceSurfaceIndex, $"{path}.SourceSurfaceIndex: expected {expected.SourceSurfaceIndex}, got {actual.SourceSurfaceIndex}");
Assert.True(expected.RetailSurfaceMask == actual.RetailSurfaceMask, $"{path}.RetailSurfaceMask: expected {expected.RetailSurfaceMask}, got {actual.RetailSurfaceMask}"); Assert.True(expected.RetailSurfaceMask == actual.RetailSurfaceMask, $"{path}.RetailSurfaceMask: expected {expected.RetailSurfaceMask}, got {actual.RetailSurfaceMask}");
Assert.True(expected.RawSurfaceType == actual.RawSurfaceType, $"{path}.RawSurfaceType: expected {expected.RawSurfaceType}, got {actual.RawSurfaceType}"); Assert.True(expected.RawSurfaceType == actual.RawSurfaceType, $"{path}.RawSurfaceType: expected {expected.RawSurfaceType}, got {actual.RawSurfaceType}");
Assert.True(BitConverter.SingleToInt32Bits(expected.SurfaceOpacity)
== BitConverter.SingleToInt32Bits(actual.SurfaceOpacity),
$"{path}.SurfaceOpacity: expected {expected.SurfaceOpacity:R}, got {actual.SurfaceOpacity:R}");
Assert.True(expected.IsCellShell == actual.IsCellShell, $"{path}.IsCellShell: expected {expected.IsCellShell}, got {actual.IsCellShell}"); Assert.True(expected.IsCellShell == actual.IsCellShell, $"{path}.IsCellShell: expected {expected.IsCellShell}, got {actual.IsCellShell}");
} }

View file

@ -198,6 +198,7 @@ public class ObjectMeshDataSerializerTests {
SourceSurfaceIndex = 7, SourceSurfaceIndex = 7,
RetailSurfaceMask = 0b0000_1101, RetailSurfaceMask = 0b0000_1101,
RawSurfaceType = 0x11u, RawSurfaceType = 0x11u,
SurfaceOpacity = 0.25f,
IsCellShell = true, IsCellShell = true,
}, },
}; };
@ -265,6 +266,30 @@ public class ObjectMeshDataSerializerTests {
ObjectMeshDataEquality.AssertEqual(original, readBack); ObjectMeshDataEquality.AssertEqual(original, readBack);
} }
[Theory]
[InlineData(1f)]
[InlineData(0.75f)]
[InlineData(0.5f)]
[InlineData(0.25f)]
[InlineData(0f)]
public void SurfaceOpacity_RoundTripsBitExactlyAndDeterministically(float opacity) {
ObjectMeshData data = WithCellShellSubset();
TextureBatchData batch = Assert.Single(Assert.Single(data.TextureBatches).Value);
batch.SurfaceOpacity = opacity;
using var first = new MemoryStream();
ObjectMeshDataSerializer.Write(data, first);
using var second = new MemoryStream();
ObjectMeshDataSerializer.Write(data, second);
Assert.Equal(first.ToArray(), second.ToArray());
TextureBatchData read = Assert.Single(Assert.Single(
ObjectMeshDataSerializer.Read(first.ToArray()).TextureBatches).Value);
Assert.Equal(
BitConverter.SingleToInt32Bits(opacity),
BitConverter.SingleToInt32Bits(read.SurfaceOpacity));
}
// ---- determinism ----------------------------------------------------- // ---- determinism -----------------------------------------------------
[Fact] [Fact]

View file

@ -10,15 +10,13 @@ public class PakFormatTests {
} }
/// <summary> /// <summary>
/// OH2/S1 contract §8.3 point 5: the recipe 7 -&gt; 8 bump (exact /// S5-c4: recipe 9 carries authored surface opacity in every prepared
/// CellStruct surface-index subset construction) is a serialized /// texture batch. It remains a payload recipe change, not pak framing.
/// PAYLOAD/recipe change, not a container framing change — this pin
/// makes that decision explicit and test-visible rather than implicit.
/// </summary> /// </summary>
[Fact] [Fact]
public void FormatVersion_StaysAtTwo_AcrossTheOH2RecipeBump() { public void FormatVersion_StaysAtTwo_AcrossTheS5C4RecipeBump() {
Assert.Equal(2u, PakFormat.CurrentFormatVersion); Assert.Equal(2u, PakFormat.CurrentFormatVersion);
Assert.Equal(8u, PakFormat.CurrentBakeToolVersion); Assert.Equal(9u, PakFormat.CurrentBakeToolVersion);
} }
[Fact] [Fact]

View file

@ -136,12 +136,11 @@ public sealed class PreparedAssetSourceTests : IDisposable
} }
/// <summary> /// <summary>
/// OH2/S1 contract §10.5: an OLDER-recipe (7) package must be rejected /// S5-c4: an older recipe-8 package must be rejected by a recipe-9
/// by a CURRENT-recipe (8) consumer through the catalog-identity check /// consumer through the catalog-identity check
/// alone, BEFORE any TextureBatchData payload field is ever /// alone, BEFORE any TextureBatchData payload field is ever
/// deserialized -- so an older pak can never be silently misread as /// deserialized -- so an older pak can never be silently misread as
/// carrying the new SourceSurfaceIndex/RetailSurfaceMask/RawSurfaceType/ /// carrying authored surface opacity.
/// IsCellShell fields.
/// </summary> /// </summary>
[Fact] [Fact]
public void Constructor_RejectsOlderRecipePackageBeforePayloadDecode() public void Constructor_RejectsOlderRecipePackageBeforePayloadDecode()
@ -161,7 +160,7 @@ public sealed class PreparedAssetSourceTests : IDisposable
fs.Position = 36; fs.Position = 36;
Span<byte> buf = stackalloc byte[4]; Span<byte> buf = stackalloc byte[4];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian( System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
buf, PakFormat.CurrentBakeToolVersion - 1); buf, 8u);
fs.Write(buf); fs.Write(buf);
} }
@ -169,6 +168,7 @@ public sealed class PreparedAssetSourceTests : IDisposable
() => new PakPreparedAssetSource(path, Identity)); () => new PakPreparedAssetSource(path, Identity));
Assert.Contains("does not match the installed DAT set", error.Message); Assert.Contains("does not match the installed DAT set", error.Message);
Assert.Contains("Re-bake", error.Message); Assert.Contains("Re-bake", error.Message);
Assert.Contains("bake tool 8 != 9", error.Message);
} }
[Fact] [Fact]

View file

@ -20,9 +20,9 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
// Arrange — three groups: 2 opaque (12+1 instances) + 1 transparent (12 instances) // Arrange — three groups: 2 opaque (12+1 instances) + 1 transparent (12 instances)
var groups = new List<WbDrawDispatcher.IndirectGroupInput> var groups = new List<WbDrawDispatcher.IndirectGroupInput>
{ {
new(IndexCount: 100, FirstIndex: 0, BaseVertex: 0, InstanceCount: 12, FirstInstance: 0, TextureIndex: 0xAA, TextureLayer: 0, Translucency: TranslucencyKind.Opaque), new(IndexCount: 100, FirstIndex: 0, BaseVertex: 0, InstanceCount: 12, FirstInstance: 0, TextureIndex: 0xAA, TextureLayer: 0, Translucency: TranslucencyKind.Opaque, SurfaceOpacity: 0.75f),
new(IndexCount: 200, FirstIndex: 100, BaseVertex: 0, InstanceCount: 12, FirstInstance: 12, TextureIndex: 0xBB, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend), new(IndexCount: 200, FirstIndex: 100, BaseVertex: 0, InstanceCount: 12, FirstInstance: 12, TextureIndex: 0xBB, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend, SurfaceOpacity: 0.25f),
new(IndexCount: 50, FirstIndex: 300, BaseVertex: 100, InstanceCount: 1, FirstInstance: 24, TextureIndex: 0xCC, TextureLayer: 0, Translucency: TranslucencyKind.Opaque), new(IndexCount: 50, FirstIndex: 300, BaseVertex: 100, InstanceCount: 1, FirstInstance: 24, TextureIndex: 0xCC, TextureLayer: 0, Translucency: TranslucencyKind.Opaque, SurfaceOpacity: 0.5f),
}; };
var indirect = new DrawElementsIndirectCommand[16]; var indirect = new DrawElementsIndirectCommand[16];
@ -60,6 +60,9 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
Assert.Equal(0xAAu, batch[0].TextureIndex); Assert.Equal(0xAAu, batch[0].TextureIndex);
Assert.Equal(0xCCu, batch[1].TextureIndex); Assert.Equal(0xCCu, batch[1].TextureIndex);
Assert.Equal(0xBBu, batch[2].TextureIndex); Assert.Equal(0xBBu, batch[2].TextureIndex);
Assert.Equal(0.75f, batch[0].SurfaceOpacity);
Assert.Equal(0.5f, batch[1].SurfaceOpacity);
Assert.Equal(0.25f, batch[2].SurfaceOpacity);
Assert.Equal(CullMode.CounterClockwise, cull[0]); Assert.Equal(CullMode.CounterClockwise, cull[0]);
Assert.Equal(CullMode.CounterClockwise, cull[1]); Assert.Equal(CullMode.CounterClockwise, cull[1]);
Assert.Equal(CullMode.CounterClockwise, cull[2]); Assert.Equal(CullMode.CounterClockwise, cull[2]);
@ -184,8 +187,8 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
]; ];
// Command 1 is mixed: instance 2 is ordinary and instance 3 is a // Command 1 is mixed: instance 2 is ordinary and instance 3 is a
// building. It must be submitted once, then mesh_detail filters the // building. It must form a solo one-pass detail run. Commands 0 and 2
// ordinary instance. Commands 0 and 2 must never reach the detail pipe. // remain neutral ordinary runs.
uint[] mixedCategories = [0u, 0u, 0u, 1u, 0u]; uint[] mixedCategories = [0u, 0u, 0u, 1u, 0u];
Assert.True(WbDrawDispatcher.TryGetNextDetailCommandRun( Assert.True(WbDrawDispatcher.TryGetNextDetailCommandRun(
commands, commands,
@ -238,17 +241,13 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
// without copying. The cast is only safe if the structs have identical // without copying. The cast is only safe if the structs have identical
// layout (size, field offsets). // layout (size, field offsets).
// //
// Campaign V slice V2 (2026-07-27): TextureHandle (ulong, an // S5-c4 consumes the former pad word as authored SurfaceOpacity. The
// ARB_bindless_texture handle) became TextureIndex (uint) plus an // struct stays 16 bytes and TextureLayer/Flags keep their offsets
// explicit Reserved pad word — a slot into the GL-only binding=9 handle // (8/12), matching GpuBindingModel.GpuBatchDataStrideBytes and both
// table instead of the raw handle (that binding, GpuBindingModel's // world shader families.
// former StorageTextureTable, was deleted at Campaign V slice V11).
// The struct stays 16 bytes and TextureLayer/Flags keep their offsets
// (8/12), matching GpuBindingModel.GpuBatchDataStrideBytes and every
// existing CPU writer, so both structs only need 4-byte packing now.
Assert.Equal(16, System.Runtime.CompilerServices.Unsafe.SizeOf<WbDrawDispatcher.BatchDataPublic>()); Assert.Equal(16, System.Runtime.CompilerServices.Unsafe.SizeOf<WbDrawDispatcher.BatchDataPublic>());
Assert.Equal(0, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.TextureIndex))); Assert.Equal(0, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.TextureIndex)));
Assert.Equal(4, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.Reserved))); Assert.Equal(4, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.SurfaceOpacity)));
Assert.Equal(8, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.TextureLayer))); Assert.Equal(8, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.TextureLayer)));
Assert.Equal(12, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.Flags))); Assert.Equal(12, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.Flags)));
} }

View file

@ -53,4 +53,28 @@ public sealed class ContentMigrationCatalogTests
Assert.Contains("DrawingBSP", plan.Reason, StringComparison.OrdinalIgnoreCase); Assert.Contains("DrawingBSP", plan.Reason, StringComparison.OrdinalIgnoreCase);
Assert.Contains("CellStruct", plan.Reason, StringComparison.OrdinalIgnoreCase); Assert.Contains("CellStruct", plan.Reason, StringComparison.OrdinalIgnoreCase);
} }
[Fact]
public void RecipeEightToNineRequiresOneExplicitFullRebuild()
{
ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(8, 9);
Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind);
Assert.Equal(8u, plan.FromRecipeVersion);
Assert.Equal(9u, plan.TargetRecipeVersion);
Assert.Contains("surface opacity", plan.Reason, StringComparison.OrdinalIgnoreCase);
Assert.Empty(plan.EffectiveDatIds);
Assert.Empty(plan.EffectiveLandblocks);
}
[Fact]
public void AnyOlderRecipeToNineCollapsesToOneFullRebuild()
{
ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(1, 9);
Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind);
Assert.Equal(9u, plan.TargetRecipeVersion);
Assert.Contains("CellStruct", plan.Reason, StringComparison.OrdinalIgnoreCase);
Assert.Contains("surface opacity", plan.Reason, StringComparison.OrdinalIgnoreCase);
}
} }