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

View file

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

View file

@ -56,7 +56,7 @@ internal struct GpuPushConstants
public uint TextureIndexA;
/// <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>
public uint TextureIndexB;

View file

@ -87,7 +87,7 @@ internal sealed record VulkanDeviceFeatureSupport
/// <summary>
/// #459: the shader compiler targets Vulkan 1.3 / SPIR-V 1.6, where glslang
/// 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
/// that declares the capability without the feature enabled is invalid
/// 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),
// Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend.
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),
_ => throw new ArgumentOutOfRangeException(nameof(blend), blend, "Unknown blend mode."),
};

View file

@ -1,11 +1,10 @@
using System.Numerics;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering;
/// <summary>
/// Testable CPU statement of retail's detail-pass gate and pixel math. The
/// production pixels are produced by <c>mesh_detail</c>; keeping these facts
/// Testable CPU statement of retail's detail gate and one-pass pixel math. The
/// production pixels are produced by both world-mesh fragment families; keeping these facts
/// in one small contract makes the setting and the combine independently
/// assertable without a GPU.
///
@ -17,15 +16,12 @@ namespace AcDream.App.Rendering;
/// <c>D3DPolyRender::RenderMeshSubset</c> (0x0059ca10) never falls back to
/// the two-pass framebuffer blend the earlier #226 port reproduced; it takes
/// the single-pass texture-stage combine set up in
/// <c>D3DPolyRender::SetSurface</c> (0x0059c4d0):
/// <c>lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)</c> — a blend
/// TOWARD the detail colour by <c>detail.a * diffuse.a</c>, not the
/// fallback's <c>dest * (detail.rgb + 1 - detail.a)</c>. Built meshes light
/// with <c>tmpmaterial.Diffuse.a = 1</c> for opaque subsets
/// (<c>RenderMeshSubset</c>), so on the live Dereth category texture (mean
/// rgb 0.165, mean alpha 0.132) the combine is a mild darkening
/// (&#8776; 0.868 * base + 0.022), the opposite sign of the fallback's
/// brightening.</para>
/// <c>D3DPolyRender::SetSurface</c> (0x0059c4d0). With base RGB <c>B</c>,
/// diffuse RGB <c>D</c>, active material alpha <c>a</c>, and detail texel
/// <c>Q=(q,qA)</c>, that stage program emits
/// <c>C=q*(a*qA)+(B*D)*(1-a*qA)</c> and <c>X=a*qA*qA</c>. Base texture alpha
/// is deliberately excluded. This is not the fallback's framebuffer replay
/// result <c>dest * (detail.rgb + 1 - detail.a)</c>.</para>
///
/// <para>There is no distance fade on this path. Retail's
/// <c>ACRender::get_alpha_for_z</c> (0x006b6230) is only evaluated in
@ -44,66 +40,69 @@ internal static class RetailDetailTextureContract
settingEnabled && binding.IsAvailable;
/// <summary>
/// Opaque detail must compare equal against the depth written by its exact
/// base geometry. On an MSAA target that inherits the base pass's per-sample
/// alpha-to-coverage mask without applying A2C to the detail alpha itself.
/// Transparent bases do not write depth, so their adjacent detail uses the
/// accepted less-or-equal comparison instead.
/// Retail's exact pre-fog stage result. Base alpha is deliberately absent.
/// </summary>
internal static GpuCompareOp DetailDepthCompare(bool transparent) =>
transparent ? GpuCompareOp.LessOrEqual : GpuCompareOp.Equal;
/// <summary>
/// The exact pixel <c>mesh_detail</c> composites onto the existing
/// framebuffer colour: retail's single-pass stage-1
/// <c>BLENDCURRENTALPHA(TEXTURE, CURRENT)</c>, a lerp from
/// <paramref name="baseColour"/> toward <paramref name="detail"/>'s RGB by
/// <c>detail.a * opacity</c>. <paramref name="opacity"/> is the base
/// subset's diffuse alpha — 1 for an opaque subset, the translucency-fade
/// multiplier for a fading one — mirrored from the shader's
/// <c>instanceAlpha[instanceIndex]</c> read.
/// </summary>
internal static Vector3 Expected(Vector3 baseColour, Vector4 detail, float opacity) =>
Vector3.Lerp(
baseColour,
new Vector3(detail.X, detail.Y, detail.Z),
detail.W * opacity);
/// <summary>
/// True when the combine above is an exact no-op — either the detail
/// texel is fully transparent or the base subset's own diffuse alpha (the
/// translucency fade) has reached zero. Neutral is <c>detail.a * opacity
/// == 0</c>, not any particular colour equality.
/// </summary>
internal static bool IsNeutral(Vector4 detail, float opacity) =>
detail.W * opacity == 0f;
/// <summary>
/// Review fix (post-05970306): retail's D3D fog stage runs AFTER the
/// texture-stage combine, applying to the FINAL pixel, not to
/// <c>detail.rgb</c> in isolation. acdream draws the combine as two
/// separate passes (mesh_modern's base draw, then mesh_detail's blended
/// replay), so each draw fogs its OWN colour before the fixed-function
/// blend recombines them — this is the CPU statement of that two-draw
/// path: <c>lerp(mix(base,fog,f), mix(detail,fog,f), detail.a*opacity)</c>.
/// It is algebraically identical to retail's single-draw
/// fog-after-combine order, <c>mix(Expected(base,detail,opacity), fog,
/// f)</c> — see <c>RetailDetailTextureContractTests</c> for the identity
/// pinned numerically, and mesh_detail.frag's header comment for the
/// derivation.
/// </summary>
internal static Vector3 ExpectedFogged(
Vector3 baseColour,
internal static Vector4 Combine(
Vector4 baseTexel,
Vector3 diffuse,
Vector4 detail,
float opacity,
Vector3 fog,
float fogFactor)
float authoredOpacity,
float liveOpacity)
{
Vector3 foggedBase = Vector3.Lerp(baseColour, fog, fogFactor);
Vector3 foggedDetail = Vector3.Lerp(
new Vector3(detail.X, detail.Y, detail.Z),
fog,
fogFactor);
return Vector3.Lerp(foggedBase, foggedDetail, detail.W * opacity);
float alpha = authoredOpacity * liveOpacity;
float weight = alpha * detail.W;
Vector3 colour = new(detail.X, detail.Y, detail.Z);
colour *= weight;
colour += new Vector3(baseTexel.X, baseTexel.Y, baseTexel.Z)
* 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 vec2 vSelectionLighting; // x=luminosity, y=diffuse
in flat uint vReceivesDirectionalShadow;
in flat float vSurfaceOpacity;
in flat uint vBatchFlags;
in flat uint vDetailCategory;
#include "directional_shadow_receiver.glsl"
#include "retail_detail_material.glsl"
// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
// 0 = opaque pass — discard fragments with alpha < 0.95
@ -26,6 +30,9 @@ in flat uint vReceivesDirectionalShadow;
// transparent overdraw cost otherwise)
uniform int uRenderPass;
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.
struct Light {
@ -61,35 +68,14 @@ vec3 applyFog(vec3 lit, vec3 worldPos) {
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() {
vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer)));
// 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;
}
float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05;
// Only the authored outdoor directional term is shadowed. Ambient, local
// 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 =
// the pattern comes from texture/per-pixel machinery. Throwaway diagnostic.
if (uLightDebug == 3) {
if (color.a < alphaCutoff) discard;
FragColor = vec4(min(lit, vec3(1.0)), 1.0);
return;
}
@ -120,11 +107,30 @@ void main() {
// Retail clamp per-channel to 1.0 (r13 §13.1).
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);
// #188: multiply the FINAL alpha only — the discard thresholds above stay
// 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);
FragColor = vec4(rgb, alpha);
}

View file

@ -23,7 +23,7 @@ struct InstanceData {
// CPU writer's layout is unchanged (GpuBindingModel.GpuBatchDataStrideBytes).
struct BatchData {
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 flags; // reserved — N.5 dispatcher owns all blend state
// (glBlendFunc per pass). If a future phase wants
@ -116,6 +116,10 @@ layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf {
vec2 instanceSelectionLighting[];
};
layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
uint instanceDetailCategory[];
};
uniform mat4 uViewProjection;
// Absolute transform prefix in the shared shadow/world pose arena. Every
// 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 vec2 vSelectionLighting;
out flat uint vReceivesDirectionalShadow;
out flat float vSurfaceOpacity;
out flat uint vBatchFlags;
out flat uint vDetailCategory;
void main() {
int transformIndex = gl_BaseInstanceARB + gl_InstanceID;
@ -358,4 +365,7 @@ void main() {
// could read its flags — Campaign VM VM6.)
vTextureIndex = b.textureIndex;
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 float vOpacityMultiplier; // #188
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):
// 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
// paletted, 200/255 DDS/non-paletted). Every other value keeps 0.05.
uniform float uParamB;
uniform uint uTextureIndexA;
uniform float uParamA;
// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1.
struct Light {
@ -70,35 +77,6 @@ void main() {
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) {
// '<' 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
// lights), passed through retail CMaterial's two click-pulse replacements:
// emissive/luminosity + diffuse * scene lighting. Normal is (0,1).
@ -108,6 +86,7 @@ void main() {
// ignored). Stripes visible HERE = a vertex-lighting artifact; absent =
// the pattern comes from texture/per-pixel machinery. Throwaway diagnostic.
if (uLightDebug == 3) {
if (color.a < alphaCutoff) discard;
FragColor = vec4(min(lit, vec3(1.0)), 1.0);
return;
}
@ -118,11 +97,32 @@ void main() {
// Retail clamp per-channel to 1.0 (r13 §13.1).
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);
// #188: multiply the FINAL alpha only — the discard thresholds above stay
// 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);
FragColor = vec4(rgb, alpha);
}

View file

@ -19,7 +19,7 @@ struct InstanceData {
// CPU writer's layout is unchanged (GpuBindingModel.GpuBatchDataStrideBytes).
struct BatchData {
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 flags; // reserved — N.5 dispatcher owns all blend state
// (glBlendFunc per pass). If a future phase wants
@ -112,6 +112,10 @@ layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf {
vec2 instanceSelectionLighting[];
};
layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
uint instanceDetailCategory[];
};
uniform mat4 uViewProjection;
// 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 float vOpacityMultiplier; // #188
out flat vec2 vSelectionLighting;
out flat float vSurfaceOpacity;
out flat uint vBatchFlags;
out flat uint vDetailCategory;
void main() {
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.
vTextureIndex = b.textureIndex;
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": [
{
"stage": "vert",
"sourceSha256": "e552edb7ef42e9e32812824c075310f2d99eb5826e527db5c22b54890ef02088",
"sourceSha256": "71950823770a69d610db03e0c54c645934e3b46a686c8abac85b52f2476afcfa",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "62b001a72080ea74bdaef0fb2a4ed92533feaeab76187ce4215813c6327432e7",
"compiled": true
}
]
},
{
"name": "mesh_detail",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "2d8b5b42f053a2d42e1ff44db3e331d5bea2d3f7dd36e20d813b7ae22c033181",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "a5285c51e8d6a1fa91f403f8489819595fa5a20abaaaccc969f220ea2c4eb335",
"sourceSha256": "5eb058e92ab27f1f6c245401e05f78a278fe00696b5c77f36ae33f8428f83bf3",
"compiled": true
}
]
@ -247,12 +231,12 @@
"stages": [
{
"stage": "vert",
"sourceSha256": "dba51ce23f2b621683fc78db73f608a0c000479b8e827cd3ecc8731d46c6ee64",
"sourceSha256": "c41448edcd258313f921f3b66c0884c8f78d4d592eef1abe0804c6cb2890c58f",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "d6ae09de279e814b159e7c481d6b66f3cc37c741b3d8a249fbe2bc8156e52d4e",
"sourceSha256": "396c382af73505bc2cac7a2db5878a63166730e47096a94630c35ad8376fe565",
"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-
/// write, no-blend pipeline pair the walk's ordered stream actually uses
/// TODAY — not <c>StreamMark</c> itself: if any future ordered-stream
/// stage draws with blending or a disabled depth write (for example the
/// deferred building-detail overlay replay,
/// <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
/// stage draws with blending or a disabled depth write, that stage's
/// <c>StreamMark</c> MUST
/// become a flush point again; the contract gets amended, not bent.</para>
/// </summary>
internal void Replay(IGpuFrame frame, IGpuPassEncoder encoder)

View file

@ -28,9 +28,6 @@ public sealed unsafe partial class EnvCellRenderer
private IGpuPipeline? _alphaPipeline;
private IGpuPipeline? _clipPipeline;
private IGpuPipeline? _additivePipeline;
private IGpuPipeline? _detailPipeline;
private IGpuPipeline? _transparentDetailPipeline;
private IGpuPipeline? _clipDetailPipeline;
private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail;
private readonly Func<bool> _buildingDetailEnabled;
@ -46,11 +43,12 @@ public sealed unsafe partial class EnvCellRenderer
/// to the retail alpha queue for later replay.
/// </summary>
internal bool TransparentDetailEnabled =>
RetailDetailTextureContract.ShouldRender(_buildingDetailEnabled(), _environmentDetail);
RetailDetailTextureContract.ShouldRender(_buildingDetailEnabled(), _environmentDetail)
&& _environmentDetail.Tiling != 0f;
/// <summary>
/// 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.
/// </summary>
internal EnvCellRenderer(
@ -82,33 +80,6 @@ public sealed unsafe partial class EnvCellRenderer
scope.SampleCount);
_additivePipeline = CreateShellPipeline(
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;
}
@ -267,7 +238,7 @@ public sealed unsafe partial class EnvCellRenderer
BindRingSection<int>(
encoder, frame, GpuBindingModel.StorageInstanceLightSets,
_lightSetData.AsSpan(0, uniqueInstanceCount * lightStride));
BindEnvironmentDetailCategory(encoder, frame);
BindEnvironmentDetailCategory(encoder, frame, uniqueInstanceCount);
// The frame-global sections, bound after this renderer's own binds
// 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);
IGpuBuffer commandBuffer = commands.Buffer;
uint commandBase = commands.OffsetBytes;
bool detailEnabled = RetailDetailTextureContract.ShouldRender(
_buildingDetailEnabled(),
_environmentDetail);
bool detailEnabled = TransparentDetailEnabled;
for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++)
{
@ -324,56 +293,14 @@ public sealed unsafe partial class EnvCellRenderer
pushConstants.ParamB = isClip
? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
: 0f;
pushConstants.TextureIndexA = detailEnabled
? _environmentDetail.TextureSlot.Index
: 0u;
pushConstants.ParamA = detailEnabled
? _environmentDetail.Tiling
: 0f;
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(
commandBuffer,
commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)),
@ -381,39 +308,10 @@ public sealed unsafe partial class EnvCellRenderer
(uint)sizeof(DrawElementsIndirectCommand));
}
// Retail DrawEnvCell category (2). Replay only the already-filtered
// opaque shell commands. Transparent ClipMap is excluded here: it
// takes the immediate/delayed CLIP path and, when detail is active,
// its own _clipDetailPipeline contribution. No distance fade
// (VM1/VM2): retail's noFadeDetail gates get_alpha_for_z to the
// immediate-polygon path only, which built meshes never reach;
// attenuation is the sampler's linear mip chain converging to the
// texture mean. The existing "Building Detail Textures" option gates
// both this and buildings, matching LScape::ChangeRegion.
if (renderPass == WbRenderPass.Opaque
&& detailEnabled)
{
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));
}
}
pushConstants.TextureIndexA = 0;
pushConstants.ParamA = 0f;
pushConstants.ParamB = 0f;
encoder.SetPushConstants(in pushConstants);
}
private void BindPipelineWithMesh(
@ -502,23 +400,23 @@ public sealed unsafe partial class EnvCellRenderer
}
/// <summary>
/// Binds one category word for <c>mesh_detail.vert</c>'s statically used
/// binding 9. The vertex shader treats only exact <c>uParamB=1</c> as the
/// object/building-category sentinel; ordinary EnvCell zero and the CLIP
/// references 100/255 and 200/255 therefore keep the environment category.
/// Vulkan still requires the declared descriptor to be valid.
/// Binds one EnvCell category word per instance for the world vertex
/// shader's statically used binding 9. Vulkan requires the declared
/// descriptor to be valid even when detail is unarmed.
/// </summary>
internal static void BindEnvironmentDetailCategory(
internal void BindEnvironmentDetailCategory(
IGpuPassEncoder encoder,
IGpuFrame frame)
IGpuFrame frame,
int instanceCount)
{
Span<uint> category = stackalloc uint[1];
category[0] = 1u;
if (_detailCategoryData.Length < instanceCount)
Array.Resize(ref _detailCategoryData, Math.Max(instanceCount, 16));
Array.Fill(_detailCategoryData, 1u, 0, instanceCount);
BindRingSection<uint>(
encoder,
frame,
GpuBindingModel.StorageInstanceDetailCategory,
category);
_detailCategoryData.AsSpan(0, instanceCount));
}
private void DisposeRhiResources()
@ -531,11 +429,5 @@ public sealed unsafe partial class EnvCellRenderer
_clipPipeline = null;
_additivePipeline?.Dispose();
_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>();
// 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).
// Before this field the shell and interior-detail passes never bound that
// storage buffer at all, so both shaders read whatever section
// Before this field the shell pass never bound that storage buffer at
// all, so the shader read whatever section
// WbDrawDispatcher's own SubmitRhi last bound in the same pass — an
// unrelated object's opacity array, indexed by these cell instance ids.
// 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<...>()
private DrawElementsIndirectCommand[] _commands = Array.Empty<DrawElementsIndirectCommand>();
private ModernBatchData[] _modernBatches = Array.Empty<ModernBatchData>();
private uint[] _detailCategoryData = Array.Empty<uint>();
private readonly List<EnvCellLandblock> _prepareLandblocks = new();
private readonly List<InstanceData> _renderInstances = 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,
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
// answers whether this renderer is ready to draw.
if (!_initialized) return;
@ -1312,6 +1313,7 @@ public sealed partial class EnvCellRenderer :
// Slice V4t: the slot is the device's, assigned by the
// mesh manager at upload rather than interned here.
TextureTableIndex = item.batch.TextureSlot.Index,
SurfaceOpacity = item.batch.SurfaceOpacity,
TextureIndex = (uint)item.batch.TextureIndex,
// #226: this renderer submits built EnvCell meshes.
// Retail DrawMesh forwards curr_detail_surface to

View file

@ -6,7 +6,7 @@ namespace AcDream.App.Rendering.Wb;
/// <summary>
/// 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
/// (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
/// <c>directional_shadow_world_*.vert</c> caster shaders read these bits
/// through the shared <c>foliage_wind.glsl</c> include

View file

@ -52,4 +52,5 @@ internal readonly record struct GroupKey(
uint TextureLayer,
TranslucencyKind Translucency,
uint FoliageFlags,
float SurfaceOpacity = 1f,
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
/// bindless handle) and the layer index within the shared/pooled array.
/// 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.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct ModernBatchData {
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 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 IsAdditive { 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
public uint FirstIndex { get; set; }
@ -2263,6 +2265,7 @@ namespace AcDream.App.Rendering.Wb
IsTransparent = batch.IsTransparent,
IsAdditive = batch.IsAdditive,
HasWrappingUVs = batch.HasWrappingUVs,
SurfaceOpacity = batch.SurfaceOpacity,
Key = batch.Key,
CullMode = batch.CullMode,
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
/// several — see <c>OrderPreservingSubmitterTests</c>).</para>
///
/// <para>Scope: this stage proves walk-order submission through the existing
/// RHI on static content (plan §FW2). It does NOT wire the retail
/// building-detail overlay replay (<c>DrawBuildingDetailRangeRhi</c>'s second
/// 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>
/// <para>OVERHAUL S5-c4 arms retail detail on a detail-category command's
/// existing solo base run. It remains one draw and therefore introduces no
/// blended overlay or additional terrain/StreamMark flush boundary.</para>
/// </summary>
public sealed unsafe partial class WbDrawDispatcher
{
@ -395,6 +390,7 @@ public sealed unsafe partial class WbDrawDispatcher
_batchData[i] = new BatchData
{
TextureIndex = key.TextureSlot.Index,
SurfaceOpacity = key.SurfaceOpacity,
TextureLayer = key.TextureLayer,
Flags = 1u | key.FoliageFlags,
};
@ -545,6 +541,10 @@ public sealed unsafe partial class WbDrawDispatcher
IGpuBuffer commandBuffer = _orderedCommands.Buffer!;
uint commandBase = _orderedCommands.OffsetBytes;
int rangeEnd = firstCommand + commandCount;
bool detailEnabled = RetailDetailTextureContract.ShouldRender(
_buildingDetailEnabled(),
_buildingDetail)
&& _buildingDetail.Tiling != 0f;
foreach (OrderedMergeRun run in _orderedRuns)
{
@ -569,6 +569,11 @@ public sealed unsafe partial class WbDrawDispatcher
PipelineBucket bucket = BucketFor(_orderedStream.Keys[run.FirstCommand].Translucency);
IGpuPipeline pipeline = PipelineForBucket(pipelines, bucket);
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);
DrawIndirectRangeRhi(
@ -580,6 +585,9 @@ public sealed unsafe partial class WbDrawDispatcher
run.FirstCommand,
run.CommandCount);
}
ClearDetailPushConstants(ref pushConstants);
encoder.SetPushConstants(in pushConstants);
}
/// <summary>

View file

@ -33,7 +33,7 @@ public sealed unsafe partial class WbDrawDispatcher
private readonly IWorldPassScope? _scope;
/// <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
/// 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 AlphaBlend,
IGpuPipeline AlphaAdditive,
IGpuPipeline AlphaInverse,
IGpuPipeline RetailDetail,
IGpuPipeline RetailDetailTransparent);
IGpuPipeline AlphaInverse);
private MeshPipelineSet? _backbufferPipelines;
private MeshPipelineSet? _offscreenPipelines;
private const string OpaqueTimerScope = "wb-entities-opaque";
private const string TransparentTimerScope = "wb-entities-transparent";
private const string DetailTimerScope = "wb-buildings-detail";
private readonly TerrainAtlas.RetailDetailTextureBinding _buildingDetail;
private readonly Func<bool> _buildingDetailEnabled;
@ -158,8 +155,7 @@ public sealed unsafe partial class WbDrawDispatcher
/// <summary>
/// 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
/// the detail overlay compiles <c>mesh_detail</c> from committed SPIR-V;
/// <c>BindlessSupport</c>: five base pipelines compile <c>mesh_modern</c>;
/// batch data already carries the device's own
/// <c>GpuTextureSlot</c> (V4t) rather than a bindless handle.
/// </summary>
@ -231,7 +227,7 @@ public sealed unsafe partial class WbDrawDispatcher
bool usesRenderPackShaderAbi = false)
{
string suffix = samples > 1 ? string.Empty : "-1x";
var created = new List<IGpuPipeline>(7);
var created = new List<IGpuPipeline>(5);
try
{
return new MeshPipelineSet(
@ -260,28 +256,6 @@ public sealed unsafe partial class WbDrawDispatcher
device, $"{namePrefix}-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples,
shaders: baseShaders,
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)));
}
catch
@ -441,10 +415,12 @@ public sealed unsafe partial class WbDrawDispatcher
transformBaseInstance);
IGpuBuffer commandBuffer = commands.Buffer;
uint commandBase = commands.OffsetBytes;
ReadOnlySpan<DrawElementsIndirectCommand> usedCommands =
_indirectCommands.AsSpan(0, totalDraws);
ReadOnlySpan<uint> usedDetailCategories =
_detailCategoryData.AsSpan(0, immediateInstances);
bool detailEnabled = RetailDetailTextureContract.ShouldRender(
_buildingDetailEnabled(),
_buildingDetail)
&& _buildingDetail.Tiling != 0f;
// ── Phase 7: opaque pass ─────────────────────────────────────────────
if (_opaqueDrawCount > 0)
@ -459,67 +435,12 @@ public sealed unsafe partial class WbDrawDispatcher
encoder.SetPushConstants(in pushConstants);
using (BeginRhiTimer(encoder, diag, OpaqueTimerScope))
{
DrawIndirectRangeRhi(
DrawDetailAwareRangeRhi(
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 ────────────────────────────────────────
if (_transparentDrawCount > 0)
{
@ -538,7 +459,8 @@ public sealed unsafe partial class WbDrawDispatcher
ref pushConstants,
commandBuffer,
commandBase,
usedDetailCategories);
usedDetailCategories,
detailEnabled);
}
}
@ -594,7 +516,7 @@ public sealed unsafe partial class WbDrawDispatcher
/// difference between the two call sites is <paramref name="viewProjection"/>
/// (the drain reads the deferred scope's own <c>_deferredAlphaViewProjection</c>;
/// 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>
private GpuPushConstants BindAlphaDrawState(
IGpuPassEncoder encoder,
@ -703,14 +625,12 @@ public sealed unsafe partial class WbDrawDispatcher
/// <summary>
/// S4-c2 fix round 1 (M3b): <c>DrawMesh</c> row 1's "render immediately;
/// enqueue nothing" outcome for ONE translucent building-shell instance
/// under building detail — bind the blend pipeline, one indirect draw,
/// then the detail pass, reusing <see cref="BindAlphaDrawState"/> and
/// <see cref="DrawBuildingDetailRangeRhi"/> (the same code
/// <see cref="DrawPreparedAlphaBatchRhi"/> used to use) for the single
/// slot <see cref="WbDrawDispatcher.DrawImmediateAlphaInstance"/> just
/// under building detail — arm the retail one-pass material on the
/// original blend pipeline and submit the single slot
/// <see cref="WbDrawDispatcher.DrawImmediateAlphaInstance"/> just
/// prepared. The caller only reaches this method when
/// <c>detailSurfaceActive</c> was true, so the detail pass always
/// applies here — the drain's own per-command detail CHECK is gone
/// <c>detailSurfaceActive</c> was true, so detail always applies here —
/// the drain's own per-command detail CHECK is gone
/// (M3c) precisely because detail-eligible content no longer reaches it.
/// </summary>
private void DrawImmediateAlphaInstanceRhi(
@ -726,6 +646,7 @@ public sealed unsafe partial class WbDrawDispatcher
BindPipelineWithMesh(encoder, PipelineForBlend(pipelines, blend), mesh);
encoder.SetPushConstants(in pushConstants);
ArmBuildingDetail(ref pushConstants);
DrawIndirectRangeRhi(
encoder,
ref pushConstants,
@ -733,16 +654,8 @@ public sealed unsafe partial class WbDrawDispatcher
_alphaCommands.OffsetBytes,
startCommand: 0,
commandCount: 1);
DrawBuildingDetailRangeRhi(
encoder,
mesh,
pipelines.RetailDetailTransparent,
ref pushConstants,
_alphaCommands.Buffer!,
_alphaCommands.OffsetBytes,
firstCommand: 0,
commandCount: 1);
ClearDetailPushConstants(ref pushConstants);
encoder.SetPushConstants(in pushConstants);
}
private void DrawImmediateTransparentRhi(
@ -752,21 +665,21 @@ public sealed unsafe partial class WbDrawDispatcher
ref GpuPushConstants pushConstants,
IGpuBuffer commandBuffer,
uint commandBase,
ReadOnlySpan<uint> usedDetailCategories)
ReadOnlySpan<uint> usedDetailCategories,
bool detailEnabled)
{
bool detailEnabled = RetailDetailTextureContract.ShouldRender(
_buildingDetailEnabled(),
_buildingDetail);
int command = _opaqueDrawCount;
int end = command + _transparentDrawCount;
while (command < end)
{
TranslucencyKind blend = _groupInputScratch[command].Translucency;
bool hasDetail = detailEnabled
&& CommandContainsDetailCategory(
_indirectCommands[command],
usedDetailCategories);
int runEnd = command + 1;
while (runEnd < end
&& _groupInputScratch[runEnd].Translucency == blend
&& !hasDetail
&& (!detailEnabled
|| !CommandContainsDetailCategory(
@ -776,8 +689,14 @@ public sealed unsafe partial class WbDrawDispatcher
runEnd++;
}
BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh);
ClearDetailPushConstants(ref pushConstants);
BindPipelineWithMesh(
encoder,
PipelineForBlend(pipelines, blend),
mesh);
if (hasDetail)
ArmBuildingDetail(ref pushConstants);
else
ClearDetailPushConstants(ref pushConstants);
DrawIndirectRangeRhi(
encoder,
ref pushConstants,
@ -785,45 +704,60 @@ public sealed unsafe partial class WbDrawDispatcher
commandBase,
command,
runEnd - command);
if (hasDetail)
{
DrawBuildingDetailRangeRhi(
encoder,
mesh,
pipelines.RetailDetailTransparent,
ref pushConstants,
commandBuffer,
commandBase,
command,
1);
}
command = runEnd;
}
ClearDetailPushConstants(ref pushConstants);
encoder.SetPushConstants(in pushConstants);
}
private void DrawBuildingDetailRangeRhi(
private void DrawDetailAwareRangeRhi(
IGpuPassEncoder encoder,
GlobalMeshBuffer mesh,
IGpuPipeline detailPipeline,
ref GpuPushConstants pushConstants,
IGpuBuffer commandBuffer,
uint commandBase,
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.ParamA = _buildingDetail.Tiling;
pushConstants.ParamB = 1f;
DrawIndirectRangeRhi(
encoder,
ref pushConstants,
commandBuffer,
commandBase,
firstCommand,
commandCount);
ClearDetailPushConstants(ref pushConstants);
}
private static void ClearDetailPushConstants(
@ -1139,11 +1073,6 @@ public sealed unsafe partial class WbDrawDispatcher
totalMs += transparentMs;
any = true;
}
if (_device.Timers.TryResolve(DetailTimerScope, out double detailMs))
{
totalMs += detailMs;
any = true;
}
if (!any)
return;
@ -1174,8 +1103,6 @@ public sealed unsafe partial class WbDrawDispatcher
pipelines.AlphaBlend.Dispose();
pipelines.AlphaAdditive.Dispose();
pipelines.AlphaInverse.Dispose();
pipelines.RetailDetail.Dispose();
pipelines.RetailDetailTransparent.Dispose();
}
private sealed class NullRhiTimerScope : IDisposable

View file

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

View file

@ -617,8 +617,8 @@ public sealed partial class WbDrawDispatcher : IDisposable
private int _transparentDrawCount;
private int _transparentByteOffset;
// Campaign V slice V2 (2026-07-27): std430 layout: uint TextureIndex at
// offset 0, uint Reserved (pad) at offset 4, uint TextureLayer at offset 8,
// std430 layout: uint TextureIndex at offset 0, float SurfaceOpacity at
// offset 4, uint TextureLayer at offset 8,
// uint Flags at offset 12. Total 16 bytes — unchanged from before V2, so
// every existing CPU writer's offsets are unchanged (see
// GpuBindingModel.GpuBatchDataStrideBytes). TextureIndex used to be a
@ -630,7 +630,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
private struct BatchData
{
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 Flags;
}
@ -1947,6 +1947,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
_batchData[i] = new BatchData
{
TextureIndex = _batchPublicScratch[i].TextureIndex,
SurfaceOpacity = _batchPublicScratch[i].SurfaceOpacity,
TextureLayer = _batchPublicScratch[i].TextureLayer,
Flags = _batchPublicScratch[i].Flags,
};
@ -2049,6 +2050,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
TextureIndex: g.TextureSlot.Index,
TextureLayer: g.TextureLayer,
Translucency: g.Translucency,
SurfaceOpacity: g.SurfaceOpacity,
CullMode: g.CullMode,
FoliageFlags: g.FoliageFlags);
@ -2122,6 +2124,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
g.TextureLayer,
g.Translucency,
g.FoliageFlags,
g.SurfaceOpacity,
g.CullMode);
/// <summary>
@ -2143,6 +2146,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
Translucency = key.Translucency,
CullMode = key.CullMode,
FoliageFlags = key.FoliageFlags,
SurfaceOpacity = key.SurfaceOpacity,
Registration = registration,
LastUsedFrame = frame,
};
@ -2478,7 +2482,8 @@ public sealed partial class WbDrawDispatcher : IDisposable
{
byte mask = RetailAlphaMeshRouter.MaskFromTranslucencyKind(kind);
bool detailSurfaceActive = isBuildingShell
&& RetailDetailTextureContract.ShouldRender(_buildingDetailEnabled(), _buildingDetail);
&& RetailDetailTextureContract.ShouldRender(_buildingDetailEnabled(), _buildingDetail)
&& _buildingDetail.Tiling != 0f;
RetailAlphaMeshDecision decision = RetailAlphaMeshRouter.Route(
currentlyDrawingSky: false,
delayMask: RetailAlphaMeshRouter.DefaultDelayMask,
@ -2558,11 +2563,12 @@ public sealed partial class WbDrawDispatcher : IDisposable
{
// Campaign V slice V2: table slot, not the raw handle.
TextureIndex = key.TextureSlot.Index,
SurfaceOpacity = key.SurfaceOpacity,
TextureLayer = key.TextureLayer,
// DrawMesh invokes RenderMeshSubset with detail enabled for
// every built-mesh material subset while curr_detail_surface
// 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 —
// every delayed-alpha entry IS a built-mesh alpha submission
// — but the previous hardcoded `1` silently dropped bits 1/2
@ -3431,6 +3437,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
uint TextureIndex,
uint TextureLayer,
TranslucencyKind Translucency,
float SurfaceOpacity = 1f,
CullMode CullMode = CullMode.CounterClockwise,
// Campaign VM VM6: FoliageWindClassification.CutoutFoliageFlag /
// 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 uint TextureIndex;
public uint Reserved;
public float SurfaceOpacity;
public uint TextureLayer;
public uint Flags;
}
@ -3496,7 +3503,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
var bd = new BatchDataPublic
{
TextureIndex = g.TextureIndex,
Reserved = 0,
SurfaceOpacity = g.SurfaceOpacity,
TextureLayer = g.TextureLayer,
// #226: this is the built-mesh path. Retail DrawMesh passes
// curr_detail_surface through RenderMeshSubset for opaque,
@ -3668,6 +3675,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned;
public uint TextureLayer; // Layer in either the pooled composite array or WB shared atlas.
public TranslucencyKind Translucency;
public float SurfaceOpacity = 1f;
public CullMode CullMode;
public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes)
public int InstanceCount;
@ -3717,7 +3725,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
public readonly List<uint> IndoorFlags = new();
// #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();
// #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
/// meshes, particles, portal punches, the directional shadow caster/receiver
/// pair). The two <c>RetailDetail</c> pipelines
/// (<see cref="RetailDetailTextureContract"/>'s <c>Equal</c>/<c>LessOrEqual</c>
/// pair, used by the building-detail overlay replay) are a documented
/// exception with their own citation and are untouched by this contract.</para>
/// pair). Detail-active subsets retain those same base pipeline depth states;
/// their material combine occurs inside the original fragment shader.</para>
/// </summary>
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
/// fans, planes handed per draw through the TerrainClip UBO). S3 review
/// 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>gl_ClipDistance</c> loop outright: the per-cell clip-region table
/// <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
/// Draw Distance is LIVE through <c>ApplyLandscapeDrawDistance</c>. Building
/// 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
/// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no
/// 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:
// #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(
listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString,
read: () => bindings.LoadDisplay().LandscapeTextureDetail,
@ -1336,7 +1336,7 @@ public static class ConfigOptionsPageController
listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString,
read: () => bindings.LoadDisplay().BuildingDetailTextures,
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(
listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString,

View file

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

View file

@ -174,6 +174,13 @@ public class TextureBatchData {
public bool IsAdditive { 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>
/// Retail source surface-array index this subset was constructed from
/// (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.RawSurfaceType);
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(
@ -252,6 +255,7 @@ public static class ObjectMeshDataSerializer {
batch.RetailSurfaceMask = r.ReadByte();
batch.RawSurfaceType = r.ReadUInt32();
batch.IsCellShell = r.ReadBoolean();
batch.SurfaceOpacity = r.ReadSingle();
return batch;
}

View file

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

View file

@ -57,6 +57,10 @@ public static class ContentMigrationCatalog
7,
8,
"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)

View file

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

View file

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