fix(rendering): repair EnvCell retail CLIP state

Exclude deferred EnvCell subsets from the opaque turn, preserve exact CLIP/ALPHA fixed-state groups through leaf replay, and use retail's row-3 override, blend, depth, and texture-class alpha references. Keep the existing building-detail sentinel distinct from the two CLIP references.

Bound rejected source payload, replace no-op allocation proofs with actual EnvCell and particle RHI paths, rebuild checked-in SPIR-V, and correct AP-238/AP-240 plus the S4-c2 evidence record.

Gates: Release 0W/0E; hermetic 16735/0/0; InstalledDat 255 pass/10 known fail/1 skip; shaders 32/32; focused 239/239; allocation 2/2 at 0 B. The evidence/comment repair re-ran Release 0W/0E, shaders 32/32, affected 37/37, and allocation 2/2; mesh_detail.vert.spv remained byte-identical at SHA-256 5346247ab7d606046943e19b28888c814e08dc6cb27cd9750096ac055457eb57.

Mutation proof, with each mutation restored after its named first failure:

1. Restoring the opaque predicate to !IsAdditive fails WholeLeaf_MixedCellDrawsOpaqueAtTurnThenClipAndAlphaAtDrain first at draw count: expected 1, actual 3.
2. Selecting _alphaPipeline for CLIP fails WholeLeaf_ClipDrainBindsExactStateAndTextureClassReference first at the bind sequence: expected [envcell-clip], actual [envcell-alpha].
3. Disabling CLIP depth write fails that production Theory first at Assert.True(clipPipeline.Depth.Write): expected true, actual false.
4. Swapping palette/DDS references fails the DDS row first: expected 0.784313738, actual 0.392156869; the palette row reports the inverse.
5. Mutating mesh_modern.frag from < to <= fails ClipShaders_UseGreaterEqualForThePerRangeReference first at Assert.Contains("if (color.a < alphaCutoff) discard;"): the required source spelling is absent.
6. Restoring row-3 OverrideClipmap=true fails the real-interface clip Theory first at Assert.False: expected false, actual true.
7. Deleting failed-append rollback fails the flush/end/abort rejection-storm rows first at the bounded pending-count assertion: expected 0, actual 9000.
8. Selecting _transparentDetailPipeline for a ClipMap detail contribution fails the leaf detail pin first at the bind sequence: expected second bind envcell-retail-detail-clip, actual envcell-retail-detail-alpha.
9. Resetting detail ParamB to zero fails the same detail pin first at the second pushed reference: expected 0.784313738, actual 0.
10. Classifying CLIP with exact mask equality excludes legal 0x09 and fails WholeLeaf_PositiveStippleClipMaskUsesClipPipelineAndDdsReference first at pipeline: expected envcell-clip, actual envcell-alpha.
11. Mapping the new blend to SRC_ALPHA/INVSRCALPHA fails AllRetailBlendModesAreRepresentable first at the tuple: expected (One, OneMinusSrcAlpha), actual (SrcAlpha, OneMinusSrcAlpha).
12. Restoring mesh_detail.vert's uParamB > 0.5 category predicate fails ClipShaders_UseGreaterEqualForThePerRangeReference first because vDetailCategory = uParamB == 1.0 is absent.
13. Treating every positive detail uParamB as a cutoff fails that source pin first because isRetailClipReference(uParamB) ? uParamB : 0.05 is absent.
14. Adding arbitrary 0.5 as an accepted reference to either mesh_modern.frag or mesh_detail.frag fails that source pin first at Assert.DoesNotContain("value - 0.5"); both mutations were run and reversed independently.

Retail: D3DPolyRender::SetSurface @ 0x0059c4d0; paired binary @ 0x0059c72a, 0x0059c747/0x0059c74f, 0x0059c821, 0x0059c838, 0x0059c866.
This commit is contained in:
Erik 2026-09-04 10:36:12 +02:00
parent 0aa166aa09
commit 252886e84f
21 changed files with 812 additions and 277 deletions

View file

@ -121,6 +121,14 @@ internal enum GpuBlendMode
/// <summary>Straight alpha: <c>SrcAlpha, OneMinusSrcAlpha</c>.</summary>
StraightAlpha,
/// <summary>
/// Premultiplied/source-over: <c>One, OneMinusSrcAlpha</c>. Retail pure
/// Base1ClipMap surfaces select exactly this pair in
/// <c>D3DPolyRender::SetSurface</c> @0x0059c72a when the per-entry
/// override-clipmap byte is clear.
/// </summary>
PremultipliedAlpha,
/// <summary>Additive: <c>SrcAlpha, One</c>.</summary>
Additive,

View file

@ -174,6 +174,7 @@ internal static class VulkanViewportMapping
internal static (BlendFactor Source, BlendFactor Destination) BlendFactorsOf(GpuBlendMode blend) => blend switch
{
GpuBlendMode.StraightAlpha => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
GpuBlendMode.PremultipliedAlpha => (BlendFactor.One, BlendFactor.OneMinusSrcAlpha),
GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One),
// Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend.
GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),

View file

@ -131,7 +131,7 @@ public sealed unsafe partial class ParticleRenderer
/// <summary>
/// The RHI arm's constructor. No GL context, no <c>Shader</c>, no
/// <c>BindlessSupport</c>: the five pipelines compile <c>particle</c> and
/// <c>BindlessSupport</c>: the six pipelines compile <c>particle</c> and
/// <c>particle_mesh</c> from the committed SPIR-V, and both texture sources
/// already hand out the device's own <c>GpuTextureSlot</c> (V4t).
/// </summary>
@ -267,9 +267,11 @@ public sealed unsafe partial class ParticleRenderer
});
/// <summary>
/// One mesh-particle pipeline. Same depth bracket as the billboards (see
/// <see cref="AcDream.App.Rendering.WorldDepthContract"/> for the world
/// <c>GL_LESS</c> citation); the winding is CW because
/// One mesh-particle pipeline. Every variant uses retail's world
/// <c>GL_LESS</c> compare; delayed alpha variants match the billboard
/// no-write bracket, while row 5's opaque variant depth-writes. See
/// <see cref="AcDream.App.Rendering.WorldDepthContract"/> for the compare
/// citation. The winding is CW because
/// <c>PrepareMeshPipeline</c> sets <c>glFrontFace(GL_CW)</c>, and the cull
/// mode stays DYNAMIC because it is resolved per sub-batch from the DAT's
/// own <c>CullMode</c>.

View file

@ -263,13 +263,31 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
if ((routes & EnvCellTransparentRoute.Clip) != 0)
{
int token = clipSource.AddPendingCellId(cellId);
queue.TryAppend(RetailAlphaList.Clip, clipSource, token, overrideClipmap: true);
// This fixed source is ordinary DrawMesh row 3: pure ClipMap,
// multipass false, therefore CLIP with overrideClipmap=false.
// If the queue drops at 3,000, retain no source-owned payload;
// TryAppend has already registered the source for exact reset.
if (!queue.TryAppend(
RetailAlphaList.Clip,
clipSource,
token,
overrideClipmap: false))
{
clipSource.RollbackPendingCellId(token);
}
}
if ((routes & EnvCellTransparentRoute.Alpha) != 0)
{
int token = alphaSource.AddPendingCellId(cellId);
queue.TryAppend(RetailAlphaList.Alpha, alphaSource, token, overrideClipmap: false);
if (!queue.TryAppend(
RetailAlphaList.Alpha,
alphaSource,
token,
overrideClipmap: false))
{
alphaSource.RollbackPendingCellId(token);
}
}
}
@ -297,13 +315,27 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
if ((routes & EnvCellTransparentRoute.Clip) != 0)
{
int token = clipSource.AddPendingCellId(cellId);
queue.TryAppend(RetailAlphaList.Clip, clipSource, token, overrideClipmap: true);
if (!queue.TryAppend(
RetailAlphaList.Clip,
clipSource,
token,
overrideClipmap: false))
{
clipSource.RollbackPendingCellId(token);
}
}
if ((routes & EnvCellTransparentRoute.Alpha) != 0)
{
int token = alphaSource.AddPendingCellId(cellId);
queue.TryAppend(RetailAlphaList.Alpha, alphaSource, token, overrideClipmap: false);
if (!queue.TryAppend(
RetailAlphaList.Alpha,
alphaSource,
token,
overrideClipmap: false))
{
alphaSource.RollbackPendingCellId(token);
}
}
}
@ -327,15 +359,26 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
EnvCellTransparentRoute route,
bool detailSurfaceActive);
internal sealed class EnvCellAlphaDrawSource(
RenderEnvCellsByRoute renderTransparentOrdered,
EnvCellTransparentRoute route)
: IRetailAlphaDrawSource
internal sealed class EnvCellAlphaDrawSource : IRetailAlphaDrawSource
{
private readonly RenderEnvCellsByRoute _renderTransparentOrdered;
private readonly EnvCellTransparentRoute _route;
private readonly Action? _resetObserver;
private readonly List<uint> _pendingCellIds = new();
private readonly List<uint> _preparedCellIds = new();
private readonly List<uint> _drawScratch = new();
internal EnvCellAlphaDrawSource(
RenderEnvCellsByRoute renderTransparentOrdered,
EnvCellTransparentRoute route,
Action? resetObserver = null)
{
_renderTransparentOrdered = renderTransparentOrdered
?? throw new ArgumentNullException(nameof(renderTransparentOrdered));
_route = route;
_resetObserver = resetObserver;
}
/// <summary>Returns this cell's token — its index into
/// <see cref="_pendingCellIds"/> at the moment it was added.</summary>
internal int AddPendingCellId(uint cellId)
@ -345,6 +388,22 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
return token;
}
/// <summary>
/// Rolls back only the just-reserved tail token after queue rejection.
/// Accepted indices therefore never move, while a rejection storm can
/// neither grow the pending count nor force a second geometric growth
/// beyond the 3,000-entry list's warmed capacity.
/// </summary>
internal void RollbackPendingCellId(int token)
{
if (token != _pendingCellIds.Count - 1)
{
throw new InvalidOperationException(
"Only the just-reserved EnvCell alpha token can be rolled back.");
}
_pendingCellIds.RemoveAt(token);
}
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
{
_preparedCellIds.Clear();
@ -359,7 +418,10 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
_drawScratch.Clear();
for (int i = 0; i < drawCount; i++)
_drawScratch.Add(_preparedCellIds[firstPreparedDraw + i]);
renderTransparentOrdered(_drawScratch, route, detailSurfaceActive: false);
_renderTransparentOrdered(
_drawScratch,
_route,
detailSurfaceActive: false);
}
public void ResetAlphaSubmissions()
@ -367,9 +429,13 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
_pendingCellIds.Clear();
_preparedCellIds.Clear();
_drawScratch.Clear();
_resetObserver?.Invoke();
}
internal int PendingCount => _pendingCellIds.Count;
internal int PendingCapacity => _pendingCellIds.Capacity;
internal int PreparedCapacity => _preparedCellIds.Capacity;
internal int DrawCapacity => _drawScratch.Capacity;
}
/// <summary>

View file

@ -11,6 +11,10 @@ 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
@ -50,6 +54,11 @@ 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;
}
// 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,
@ -100,7 +109,8 @@ void main() {
vec4 base = ACDREAM_SAMPLE_ARRAY(
vBaseTextureIndex,
vec3(vBaseUv, float(vBaseTextureLayer)));
if (base.a < 0.05)
float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05;
if (base.a < alphaCutoff)
discard;
vec4 detail = ACDREAM_SAMPLE_ARRAY(

View file

@ -38,9 +38,9 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf {
uint instanceClipSlot[];
};
// Object renderer only: 1 for a retail building shell, 0 for ordinary
// scenery/creatures/players. EnvCellRenderer sets uParamB=0 and ignores the
// value, but still binds one valid word because Vulkan sees this static use.
// 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[];
};
@ -61,7 +61,10 @@ uniform mat4 uViewProjection;
uniform int uDrawIDOffset;
uniform uint uTextureIndexB; // absolute transform prefix in the shared pose arena
uniform float uParamA; // detail UV tiling
uniform float uParamB; // 1 = require building instance, 0 = EnvCell category
// 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;
@ -95,7 +98,7 @@ void main() {
vBaseTextureIndex = batch.textureIndex;
vBaseTextureLayer = batch.textureLayer;
vBatchFlags = batch.flags;
vDetailCategory = uParamB > 0.5
vDetailCategory = uParamB == 1.0
? instanceDetailCategory[instanceIndex]
: 1u;
}

View file

@ -22,6 +22,10 @@ in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse
// transparent overdraw cost otherwise)
uniform int uRenderPass;
uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here
// S4-c2 F3: only retail's exact pure-ClipMap alpha-test references are
// 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;
// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1.
struct Light {
@ -57,15 +61,23 @@ 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)));
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)
// '<' 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.
//
@ -84,7 +96,7 @@ void main() {
// 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;
if (color.a < alphaCutoff) discard;
}
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped

View file

@ -231,12 +231,12 @@
"stages": [
{
"stage": "vert",
"sourceSha256": "0c1eddb0fc892cb76ef4da47f727cec2516052d63247fa2682918bfd60b84fa8",
"sourceSha256": "2d8b5b42f053a2d42e1ff44db3e331d5bea2d3f7dd36e20d813b7ae22c033181",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "e037fd28cf71453792c17522c97c4c7797193823ed78ca629a4331b632f177b1",
"sourceSha256": "a5285c51e8d6a1fa91f403f8489819595fa5a20abaaaccc969f220ea2c4eb335",
"compiled": true
}
]
@ -252,7 +252,7 @@
},
{
"stage": "frag",
"sourceSha256": "12f2ceec9a8420bd273650e95251ca56c3bf3c3691fce1a539e4c5aea7abaabf",
"sourceSha256": "d6ae09de279e814b159e7c481d6b66f3cc37c741b3d8a249fbe2bc8156e52d4e",
"compiled": true
}
]

View file

@ -26,9 +26,11 @@ public sealed unsafe partial class EnvCellRenderer
private readonly IWorldPassScope? _scope;
private IGpuPipeline? _opaquePipeline;
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;
@ -48,7 +50,7 @@ public sealed unsafe partial class EnvCellRenderer
/// <summary>
/// The RHI arm's constructor. It also completes <c>Initialize</c>'s job: the
/// five pipelines ARE this renderer's program, so there is no second step
/// seven pipelines ARE this renderer's program, so there is no second step
/// and no <c>Shader</c> to hand in.
/// </summary>
internal EnvCellRenderer(
@ -72,6 +74,12 @@ public sealed unsafe partial class EnvCellRenderer
device, "envcell-opaque", GpuBlendMode.None, depthWrite: true, scope.SampleCount);
_alphaPipeline = CreateShellPipeline(
device, "envcell-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, scope.SampleCount);
_clipPipeline = CreateShellPipeline(
device,
"envcell-clip",
GpuBlendMode.PremultipliedAlpha,
depthWrite: true,
scope.SampleCount);
_additivePipeline = CreateShellPipeline(
device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount);
_detailPipeline = CreateShellPipeline(
@ -92,6 +100,15 @@ public sealed unsafe partial class EnvCellRenderer
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;
}
@ -274,12 +291,18 @@ public sealed unsafe partial class EnvCellRenderer
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
int groupIndex = drawRange.GroupIndex;
CullMode cullMode = ResolveRetailCellShellCullMode(
(CullMode)(groupIndex % 4));
(CullMode)(groupIndex % CullGroupCount));
bool isAdditive = groupIndex >= 4;
IGpuPipeline rangeBasePipeline = isAdditive
? _additivePipeline!
: _alphaPipeline!;
bool isAdditive = renderPass == WbRenderPass.Transparent
&& groupIndex >= AdditiveGroupBase
&& groupIndex < ClipDdsGroupBase;
bool isClip = renderPass == WbRenderPass.Transparent
&& groupIndex >= ClipDdsGroupBase;
IGpuPipeline rangeBasePipeline = isClip
? _clipPipeline!
: isAdditive
? _additivePipeline!
: _alphaPipeline!;
if (renderPass == WbRenderPass.Transparent)
{
// Blend state is the pipeline's; switching variants mid-pass has
@ -298,6 +321,9 @@ public sealed unsafe partial class EnvCellRenderer
? (int)renderPass | 0x100
: (int)renderPass;
pushConstants.DrawIdOffset = drawRange.FirstCommand;
pushConstants.ParamB = isClip
? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
: 0f;
encoder.SetPushConstants(in pushConstants);
// Retail DrawMesh's two-pass fallback redraws each transparent
@ -317,7 +343,9 @@ public sealed unsafe partial class EnvCellRenderer
pushConstants.DrawIdOffset = command;
pushConstants.TextureIndexA = 0;
pushConstants.ParamA = 0f;
pushConstants.ParamB = 0f;
pushConstants.ParamB = isClip
? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
: 0f;
encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect(
commandBuffer,
@ -325,12 +353,17 @@ public sealed unsafe partial class EnvCellRenderer
1,
(uint)sizeof(DrawElementsIndirectCommand));
BindPipelineWithMesh(encoder, _transparentDetailPipeline!, mesh);
BindPipelineWithMesh(
encoder,
isClip ? _clipDetailPipeline! : _transparentDetailPipeline!,
mesh);
SetCullMode(encoder, cullMode);
pushConstants.DrawIdOffset = command;
pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index;
pushConstants.ParamA = _environmentDetail.Tiling;
pushConstants.ParamB = 0f;
pushConstants.ParamB = isClip
? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
: 0f;
encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect(
commandBuffer,
@ -348,9 +381,11 @@ public sealed unsafe partial class EnvCellRenderer
(uint)sizeof(DrawElementsIndirectCommand));
}
// Retail DrawEnvCell category (2). Replay the already-filtered opaque
// shell commands, including ClipMap built-mesh subsets. No distance
// fade (VM1/VM2): retail's noFadeDetail gates get_alpha_for_z to the
// 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
@ -368,7 +403,7 @@ public sealed unsafe partial class EnvCellRenderer
{
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
CullMode cullMode = ResolveRetailCellShellCullMode(
(CullMode)(drawRange.GroupIndex % 4));
(CullMode)(drawRange.GroupIndex % CullGroupCount));
SetCullMode(encoder, cullMode);
pushConstants.DrawIdOffset = drawRange.FirstCommand;
encoder.SetPushConstants(in pushConstants);
@ -468,9 +503,10 @@ public sealed unsafe partial class EnvCellRenderer
/// <summary>
/// Binds one category word for <c>mesh_detail.vert</c>'s statically used
/// binding 9. EnvCell draws select their renderer-wide category via
/// <c>uParamB=0</c>, so the value is semantically unused, but Vulkan still
/// requires the declared descriptor to be valid.
/// 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.
/// </summary>
internal static void BindEnvironmentDetailCategory(
IGpuPassEncoder encoder,
@ -491,11 +527,15 @@ public sealed unsafe partial class EnvCellRenderer
_opaquePipeline = null;
_alphaPipeline?.Dispose();
_alphaPipeline = null;
_clipPipeline?.Dispose();
_clipPipeline = null;
_additivePipeline?.Dispose();
_additivePipeline = null;
_detailPipeline?.Dispose();
_detailPipeline = null;
_transparentDetailPipeline?.Dispose();
_transparentDetailPipeline = null;
_clipDetailPipeline?.Dispose();
_clipDetailPipeline = null;
}
}

View file

@ -142,8 +142,17 @@ public sealed partial class EnvCellRenderer :
private readonly List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> _renderDrawCalls = new();
private readonly Dictionary<ulong, List<InstanceData>> _filteredGroups = new();
private readonly HashSet<List<InstanceData>> _filteredOwnedLists = new();
// S4-c2 F3: four cull modes for each fixed-state family. The original
// eight slots were non-additive/additive only; transparent replay also
// needs distinct pure-ClipMap DDS/paletted ranges because their alpha-test
// references differ and neither state may share an MDI call with ALPHA.
private const int CullGroupCount = 4;
private const int AdditiveGroupBase = 4;
private const int ClipDdsGroupBase = 8;
private const int ClipPalettedGroupBase = 12;
private const int BatchGroupCount = 16;
private readonly List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)>[] _batchesByCullGroup =
Enumerable.Range(0, 8)
Enumerable.Range(0, BatchGroupCount)
.Select(_ => new List<(ObjectRenderBatch, int, int)>())
.ToArray();
private readonly List<int> _activeCullGroups = new(8);
@ -1214,17 +1223,8 @@ public sealed partial class EnvCellRenderer :
foreach (var batch in call.renderData.Batches)
{
// WB BaseObjectRenderManager.cs:723-731: pass-filter.
if (renderPass != WbRenderPass.SinglePass)
{
if (batch.IsAdditive)
{
if (renderPass == WbRenderPass.Opaque) continue;
}
else if (!batch.IsTransparent)
{
if (renderPass == WbRenderPass.Transparent) continue;
}
}
if (!BatchBelongsToPass(batch, renderPass))
continue;
if (renderPass == WbRenderPass.Transparent
&& transparentRoute != EnvCellTransparentRoute.All
@ -1277,17 +1277,8 @@ public sealed partial class EnvCellRenderer :
var call = drawCalls[callIndex];
foreach (var batch in call.renderData.Batches)
{
if (renderPass != WbRenderPass.SinglePass)
{
if (batch.IsAdditive)
{
if (renderPass == WbRenderPass.Opaque) continue;
}
else if (!batch.IsTransparent)
{
if (renderPass == WbRenderPass.Transparent) continue;
}
}
if (!BatchBelongsToPass(batch, renderPass))
continue;
if (renderPass == WbRenderPass.Transparent
&& transparentRoute != EnvCellTransparentRoute.All
@ -1296,7 +1287,9 @@ public sealed partial class EnvCellRenderer :
continue;
}
int groupIndex = (int)batch.CullMode + (batch.IsAdditive ? 4 : 0);
int groupIndex = ResolveBatchGroupIndex(
batch,
renderPass);
List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group =
_batchesByCullGroup[groupIndex];
if (group.Count == 0)
@ -1357,6 +1350,50 @@ public sealed partial class EnvCellRenderer :
SubmitRhi(allInstances, renderPass, totalDraws, uniqueInstanceCount);
}
/// <summary>
/// Exact WB pass membership, with <see cref="WbRenderPass.SinglePass"/>
/// intentionally unchanged. A non-additive transparent batch is delayed
/// content and must never draw during the preceding opaque cell turn.
/// </summary>
internal static bool BatchBelongsToPass(
ObjectRenderBatch batch,
WbRenderPass renderPass) => renderPass switch
{
WbRenderPass.Opaque => !batch.IsAdditive && !batch.IsTransparent,
WbRenderPass.Transparent => batch.IsAdditive || batch.IsTransparent,
WbRenderPass.SinglePass => true,
_ => false,
};
private static int ResolveBatchGroupIndex(
ObjectRenderBatch batch,
WbRenderPass renderPass)
{
int cull = (int)batch.CullMode;
if ((uint)cull >= CullGroupCount)
throw new ArgumentOutOfRangeException(nameof(batch), batch.CullMode, "Unknown cell-shell cull mode.");
// Preserve the old SinglePass grouping exactly: non-additive 0..3,
// additive 4..7. Its submission path still keeps the opaque pipeline.
if (renderPass != WbRenderPass.Transparent)
return cull + (batch.IsAdditive ? AdditiveGroupBase : 0);
if (batch.IsAdditive)
return cull + AdditiveGroupBase;
// Fixed state follows ConstructMesh's retained per-subset class, not
// the DrawMesh row action. Detail-active row 1 is Immediate, but a
// mask-0x08 subset (including legal positive-stipple 0x09) still needs
// the CLIP base pipeline/reference. ConstructMesh's priority already
// excludes additive/alpha-family rows before retaining this class.
if ((batch.RetailSurfaceMask & RetailAlphaMeshRouter.MaskClipMap) == 0)
return cull;
return cull + (batch.Key.PaletteId != 0
? ClipPalettedGroupBase
: ClipDdsGroupBase);
}
internal static void AppendMdiDrawRange(
List<MdiDrawRange> ranges,
int groupIndex,