acdream/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
Erik 75664805f8 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.
2026-09-05 02:03:13 +02:00

1116 lines
46 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Selection;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Campaign V slice V6j: the world entity dispatcher's RHI submission arm.
///
/// <para>V4c's content, re-landed as a second arm rather than a replacement —
/// §5.5.6 selected that shape after NVIDIA rendered the V4c binary 10/10 and
/// AMD's GL stack did not. Every GL statement in the sibling file is untouched;
/// everything here runs only when there is no GL context.</para>
///
/// <para>Three differences from V4c, each because the tree moved under it. The
/// texture table moved to set 2; #226 now uses storage binding 9 for the detail
/// category. The pass is BORROWED from <see cref="IWorldPassScope"/> rather
/// than opened, because the frame's one backbuffer pass resolves. And the
/// pipelines carry the device's sample count, because Vulkan requires a
/// pipeline's <c>rasterizationSamples</c> to match the pass and
/// alpha-to-coverage does nothing at one sample.</para>
/// </summary>
public sealed unsafe partial class WbDrawDispatcher
{
private readonly IGpuDevice? _device;
private readonly ICurrentGpuFrameSource? _frames;
private readonly IWorldPassScope? _scope;
/// <summary>
/// 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
/// this dispatcher draws in two passes with different counts — the
/// multisampled backbuffer world pass, and the single-sampled offscreen
/// paperdoll/appraisal target, which the contract fixes at one sample. Plan
/// §5.5.16 defect 3 named exactly this as the reason those viewports could
/// not exist on the Vulkan arm, and the answer is the same shape §5.5.8 gave
/// the depth-format problem: materialise both, select at bind time from what
/// the live pass actually is. Both are built at startup against the persisted
/// cache, so no frame ever compiles one.</para>
/// </summary>
internal sealed record MeshPipelineSet(
int SampleCount,
IGpuPipeline Opaque,
IGpuPipeline OpaqueAlphaToCoverage,
IGpuPipeline AlphaBlend,
IGpuPipeline AlphaAdditive,
IGpuPipeline AlphaInverse);
private MeshPipelineSet? _backbufferPipelines;
private MeshPipelineSet? _offscreenPipelines;
private const string OpaqueTimerScope = "wb-entities-opaque";
private const string TransparentTimerScope = "wb-entities-transparent";
private readonly TerrainAtlas.RetailDetailTextureBinding _buildingDetail;
private readonly Func<bool> _buildingDetailEnabled;
/// <summary>
/// A ring slice reduced to the three values a later bind needs. The prepared
/// alpha payload is written once and bound many times, so the allocation's
/// <c>ref struct</c> lifetime is escaped through these ordinary values.
/// </summary>
private readonly record struct RhiSection(
IGpuBuffer? Buffer,
uint OffsetBytes,
uint SizeBytes);
private readonly WorldTransformFrameArena _worldTransformFrames = new();
private long _ordinaryTransformDemandFrameSerial = -1;
private uint _ordinaryTransformDemandThisFrame;
private uint _ordinaryTransformDemandHighWater;
private RhiSection _alphaInstances;
private RhiSection _alphaBatches;
private RhiSection _alphaClipSlots;
private RhiSection _alphaGlobalLights;
private RhiSection _alphaLightSets;
private RhiSection _alphaIndoor;
private RhiSection _alphaOpacity;
private RhiSection _alphaSelectionLighting;
private RhiSection _alphaDetailCategory;
private RhiSection _alphaCommands;
private int _preparedAlphaInstanceCount;
private uint _alphaTransformBaseInstance;
/// <summary>
/// Starts the one authoritative transform address space for an enhanced
/// world frame. The compatibility overload writes the shadow prefix into a
/// frame ring; the retained overload activates the current flight slot's
/// already-published prefix. Ordinary N.5 submissions append later and use
/// absolute <c>BaseInstance</c> values into the same bound buffer/range.
/// </summary>
internal WorldTransformFrameSlice BeginDirectionalShadowTransformFrame(
IGpuFrame frame,
ReadOnlySpan<Matrix4x4> transforms)
{
ArgumentNullException.ThrowIfNull(frame);
return _worldTransformFrames.Begin(
frame,
transforms,
ResolveDirectionalShadowTransformBindingSize(transforms.Length));
}
/// <summary>
/// Resolves the one authoritative pose-buffer range before any shadow draw
/// records it. The retained owner and ordinary world appenders therefore
/// use the same demand-sized address space for the complete frame.
/// </summary>
internal uint ResolveDirectionalShadowTransformBindingSize(
int requiredPrefixInstances,
int ordinaryInstanceUpperBound = 0)
{
ArgumentOutOfRangeException.ThrowIfNegative(requiredPrefixInstances);
ArgumentOutOfRangeException.ThrowIfNegative(ordinaryInstanceUpperBound);
uint maximum = _device?.Capabilities.MaxStorageBufferRangeBytes
?? WorldTransformCapacityPolicy.VulkanGuaranteedMaxStorageBufferRangeBytes;
uint currentFrameDemand = checked(
(uint)requiredPrefixInstances + (uint)ordinaryInstanceUpperBound);
uint requiredCombinedInstances = checked(
(uint)requiredPrefixInstances + _ordinaryTransformDemandHighWater);
return WorldTransformCapacityPolicy.ResolveBindingSizeBytes(
Math.Max(currentFrameDemand, requiredCombinedInstances),
maximum);
}
internal WorldTransformFrameSlice BeginDirectionalShadowTransformFrame(
IGpuFrame frame,
in WorldTransformFrameSlice retainedShadowPrefix)
{
ArgumentNullException.ThrowIfNull(frame);
return _worldTransformFrames.BeginRetained(
frame,
in retainedShadowPrefix);
}
internal void CancelDirectionalShadowTransformFrame(IGpuFrame frame)
{
ArgumentNullException.ThrowIfNull(frame);
_worldTransformFrames.Cancel(frame);
}
internal bool HasDirectionalShadowTransformFrame(long frameSerial) =>
_worldTransformFrames.IsActiveFor(frameSerial);
internal uint DirectionalShadowTransformFrameUsedInstances =>
_worldTransformFrames.UsedInstances;
/// <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>;
/// batch data already carries the device's own
/// <c>GpuTextureSlot</c> (V4t) rather than a bindless handle.
/// </summary>
internal WbDrawDispatcher(
IGpuDevice device,
ICurrentGpuFrameSource frames,
IWorldPassScope scope,
TextureCache textures,
WbMeshAdapter meshAdapter,
EntitySpawnAdapter entitySpawnAdapter,
EntityClassificationCache classificationCache,
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades,
IRetailSelectionRenderSink? selectionSink = null,
RetailAlphaQueue? alphaQueue = null,
long? alphaScratchBudgetBytes = null,
TerrainAtlas.RetailDetailTextureBinding buildingDetail = default,
Func<bool>? buildingDetailEnabled = null,
Func<uint, float>? hierarchicalTranslucency = null)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
_textures = textures ?? throw new ArgumentNullException(nameof(textures));
_meshAdapter = meshAdapter ?? throw new ArgumentNullException(nameof(meshAdapter));
_entitySpawnAdapter = entitySpawnAdapter
?? throw new ArgumentNullException(nameof(entitySpawnAdapter));
_cache = classificationCache
?? throw new ArgumentNullException(nameof(classificationCache));
_translucencyFades = translucencyFades
?? throw new ArgumentNullException(nameof(translucencyFades));
_selectionSink = selectionSink;
_selectionLighting = selectionSink as IRetailSelectionLightingSource;
_alphaQueue = alphaQueue;
_hierarchicalTranslucency = hierarchicalTranslucency;
_alphaSource = new AlphaDrawSource(this);
_buildingDetail = buildingDetail;
_buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures;
long scratchBudget = alphaScratchBudgetBytes
?? AlphaScratchBudgetProfile.Create(
ResidencyBudgetOptions.Default.AlphaScratchBytes)
.DispatcherBytes;
_alphaScratchPolicy = new RetainedScratchCapacityPolicy(scratchBudget);
int samples = scope.SampleCount;
try
{
_backbufferPipelines = CreateMeshPipelineSet(device, samples);
// One sample is what an IGpuRenderTarget is by contract, so a second
// set only exists when the backbuffer is multisampled.
_offscreenPipelines = samples == 1
? _backbufferPipelines
: CreateMeshPipelineSet(device, 1);
}
catch
{
DisposeRhiResources();
throw;
}
}
private static bool DisableDetailTextures() => false;
private static MeshPipelineSet CreateMeshPipelineSet(
IGpuDevice device,
int samples,
string baseShaderName = "mesh_modern",
GpuShaderSet? baseShaders = null,
string namePrefix = "wb-mesh",
bool usesRenderPackShaderAbi = false)
{
string suffix = samples > 1 ? string.Empty : "-1x";
var created = new List<IGpuPipeline>(5);
try
{
return new MeshPipelineSet(
samples,
Track(CreateMeshPipeline(
device, $"{namePrefix}-opaque{suffix}", GpuBlendMode.None, true, false, samples,
shaders: baseShaders,
shaderName: baseShaderName,
usesRenderPackShaderAbi: usesRenderPackShaderAbi)),
Track(CreateMeshPipeline(
device, $"{namePrefix}-opaque-a2c{suffix}", GpuBlendMode.None, true, true, samples,
shaders: baseShaders,
shaderName: baseShaderName,
usesRenderPackShaderAbi: usesRenderPackShaderAbi)),
Track(CreateMeshPipeline(
device, $"{namePrefix}-alpha{suffix}", GpuBlendMode.StraightAlpha, false, false, samples,
shaders: baseShaders,
shaderName: baseShaderName,
usesRenderPackShaderAbi: usesRenderPackShaderAbi)),
Track(CreateMeshPipeline(
device, $"{namePrefix}-additive{suffix}", GpuBlendMode.Additive, false, false, samples,
shaders: baseShaders,
shaderName: baseShaderName,
usesRenderPackShaderAbi: usesRenderPackShaderAbi)),
Track(CreateMeshPipeline(
device, $"{namePrefix}-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples,
shaders: baseShaders,
shaderName: baseShaderName,
usesRenderPackShaderAbi: usesRenderPackShaderAbi)));
}
catch
{
for (int i = created.Count - 1; i >= 0; i--)
created[i].Dispose();
throw;
}
IGpuPipeline Track(IGpuPipeline pipeline)
{
created.Add(pipeline);
return pipeline;
}
}
/// <summary>
/// The pipeline set whose sample count matches the pass being recorded into.
/// Taken from the live pass rather than from the scope, because the offscreen
/// viewport borrows the scope with a pass of its own.
/// </summary>
private MeshPipelineSet PipelinesFor(IGpuPassEncoder encoder) =>
encoder.Pass.SampleCount > 1
? _backbufferPipelines!
: _offscreenPipelines!;
/// <summary>
/// The imperative <c>Enable/Disable/BlendFunc/DepthMask</c> brackets became
/// pipeline variants: opaque, opaque with alpha-to-coverage, and the three
/// retail blends. Cull mode and front face stay dynamic per MDI run, exactly
/// where <c>ApplyCullMode</c> sets them, because core Vulkan 1.3 makes those
/// dynamic and blend and alpha-to-coverage not.
///
/// <para>Depth compare is <see cref="AcDream.App.Rendering.WorldDepthContract.WorldCompare"/>
/// (<c>Less</c>), not the contract's <c>LessOrEqual</c> default — see that
/// type for the full citation. The short version: the world frame runs
/// under <c>GL_LESS</c> and this renderer never called <c>glDepthFunc</c>,
/// so it inherited it. Baking <c>LessOrEqual</c> would change which of two
/// coplanar retail surfaces wins.</para>
/// </summary>
private static IGpuPipeline CreateMeshPipeline(
IGpuDevice device,
string name,
GpuBlendMode blend,
bool depthWrite,
bool alphaToCoverage,
int sampleCount,
string shaderName = "mesh_modern",
GpuShaderSet? shaders = null,
GpuCompareOp depthCompare = AcDream.App.Rendering.WorldDepthContract.WorldCompare,
bool usesRenderPackShaderAbi = false) =>
device.CreatePipeline(new GpuPipelineDescription
{
Name = name,
Shaders = shaders ?? new GpuShaderSet(shaderName),
VertexLayout = GpuVertexLayout.WorldMesh,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = blend,
Depth = new GpuDepthState(Test: true, Write: depthWrite, depthCompare),
Cull = GpuCullMode.Back,
FrontFace = GpuFrontFace.Clockwise,
AlphaToCoverage = alphaToCoverage,
ColorWrite = true,
UsesRenderPackShaderAbi = usesRenderPackShaderAbi,
SampleCount = sampleCount,
});
/// <summary>
/// Records the opaque and transparent multi-draws into the borrowed world
/// pass. Phases 14 above are untouched — the bucketing, the sorts, the
/// indirect-command array and every retail fidelity decision are the same
/// CPU code on both arms; only where the bytes land differs.
/// </summary>
private void SubmitRhi(
Matrix4x4 viewProjection,
int immediateInstances,
int totalDraws,
bool diag)
{
IWorldPassScope scope = _scope!;
IGpuPassEncoder encoder = scope.RequireEncoder();
IGpuFrame frame = RequireRhiFrame();
GlobalMeshBuffer mesh = _meshAdapter.MeshManager?.GlobalBuffer
?? throw new InvalidOperationException("The shared mesh arena is not published.");
var pushConstants = new GpuPushConstants
{
ViewProjection = viewProjection,
DrawIdOffset = 0,
LightingMode = 0,
RenderPass = 0,
LightDebug = RenderingDiagnostics.LightDebugMode,
TextureIndexA = 0,
TextureIndexB = 0,
ParamA = 0f,
ParamB = 0f,
};
RhiSection instanceTransforms = WriteWorldTransformSection(
frame,
_instanceData.AsSpan(0, immediateInstances * 16),
out uint transformBaseInstance);
// Pack receiver/detail shaders subtract this shared-arena prefix for
// every parallel per-instance array while retaining the absolute pose
// lookup. The acdream default path always receives zero here.
pushConstants.TextureIndexB = transformBaseInstance;
// Bind the opaque variant first so the ring binds land on a live program;
// the transparent bracket rebinds its own variant, and push constants
// survive that switch per the encoder contract.
MeshPipelineSet pipelines = PipelinesFor(
encoder,
frame,
out DirectionalShadowFrameBinding shadowBinding);
BindPipelineWithMesh(
encoder,
AlphaToCoverage ? pipelines.OpaqueAlphaToCoverage : pipelines.Opaque,
mesh);
encoder.SetPushConstants(in pushConstants);
BindDirectionalShadowReceiver(encoder, in shadowBinding);
BindSection(
encoder,
GpuBindingModel.StorageInstances,
instanceTransforms);
BindRingSection<BatchData>(
encoder, frame, GpuBindingModel.StorageBatches,
_batchData.AsSpan(0, totalDraws));
BindRingSection<uint>(
encoder, frame, GpuBindingModel.StorageClipSlots,
_clipSlotData.AsSpan(0, immediateInstances));
BindGlobalLightsRhi(encoder, frame);
BindRingSection<int>(
encoder, frame, GpuBindingModel.StorageInstanceLightSets,
_lightSetData.AsSpan(0, immediateInstances * LightManager.MaxLightsPerObject));
BindRingSection<uint>(
encoder, frame, GpuBindingModel.StorageInstanceIndoor,
_indoorData.AsSpan(0, immediateInstances));
BindRingSection<float>(
encoder, frame, GpuBindingModel.StorageInstanceAlpha,
_alphaData.AsSpan(0, immediateInstances));
BindRingSection<Vector2>(
encoder, frame, GpuBindingModel.StorageInstanceSelectionLighting,
_selectionLightingData.AsSpan(0, immediateInstances));
BindRingSection<uint>(
encoder, frame, GpuBindingModel.StorageInstanceDetailCategory,
_detailCategoryData.AsSpan(0, immediateInstances));
AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions(
encoder, scope.Sections, frame);
AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting(
encoder, scope.Sections, frame);
GpuRingAllocation commands = WriteIndirectCommands(
frame,
_indirectCommands.AsSpan(0, totalDraws),
transformBaseInstance);
IGpuBuffer commandBuffer = commands.Buffer;
uint commandBase = commands.OffsetBytes;
ReadOnlySpan<uint> usedDetailCategories =
_detailCategoryData.AsSpan(0, immediateInstances);
bool detailEnabled = RetailDetailTextureContract.ShouldRender(
_buildingDetailEnabled(),
_buildingDetail)
&& _buildingDetail.Tiling != 0f;
// ── Phase 7: opaque pass ─────────────────────────────────────────────
if (_opaqueDrawCount > 0)
{
// Blend-off, depth-write-on and A.5 T20's alpha-to-coverage all come
// from the pipeline rather than an imperative bracket. Issue #52's
// per-pass batch offset is unchanged: the opaque section of Batches[]
// starts at index 0, and Vulkan's gl_DrawID resets per
// vkCmdDrawIndexedIndirect exactly as GL's does.
pushConstants.RenderPass = 0;
pushConstants.DrawIdOffset = 0;
encoder.SetPushConstants(in pushConstants);
using (BeginRhiTimer(encoder, diag, OpaqueTimerScope))
{
DrawDetailAwareRangeRhi(
encoder, ref pushConstants, commandBuffer, commandBase,
0, _opaqueDrawCount, usedDetailCategories, detailEnabled);
}
}
// ── Phase 8: transparent pass ────────────────────────────────────────
if (_transparentDrawCount > 0)
{
// Issue #52 again: the transparent section starts at _opaqueDrawCount.
// Without the offset each transparent draw reads the OPAQUE section
// and the lifestone crystal's texture flickers.
pushConstants.RenderPass = 1;
pushConstants.DrawIdOffset = _opaqueDrawCount;
encoder.SetPushConstants(in pushConstants);
using (BeginRhiTimer(encoder, diag, TransparentTimerScope))
{
DrawImmediateTransparentRhi(
encoder,
mesh,
pipelines,
ref pushConstants,
commandBuffer,
commandBase,
usedDetailCategories,
detailEnabled);
}
}
SampleRhiTimers(diag);
}
/// <summary>
/// Writes the prepared deferred-alpha payload into the frame ring once. The
/// sections survive as ordinary values so every later
/// <c>DrawPreparedAlphaBatch</c> binds the same bytes without recopying.
/// </summary>
private void PrepareRhiAlphaSections(int count)
{
_preparedAlphaInstanceCount = count;
IGpuFrame frame = RequireRhiFrame();
_alphaInstances = WriteWorldTransformSection(
frame,
_instanceData.AsSpan(0, count * 16),
out uint transformBaseInstance);
_alphaTransformBaseInstance = transformBaseInstance;
_alphaBatches = WriteRingSection<BatchData>(frame, _batchData.AsSpan(0, count));
_alphaClipSlots = WriteRingSection<uint>(frame, _clipSlotData.AsSpan(0, count));
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int uploadCount = lightCount > 0 ? lightCount : 1;
_alphaGlobalLights = WriteRingSection<float>(
frame,
_globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight));
_alphaLightSets = WriteRingSection<int>(
frame,
_lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject));
_alphaIndoor = WriteRingSection<uint>(frame, _indoorData.AsSpan(0, count));
_alphaOpacity = WriteRingSection<float>(frame, _alphaData.AsSpan(0, count));
_alphaSelectionLighting = WriteRingSection<Vector2>(
frame,
_selectionLightingData.AsSpan(0, count));
_alphaDetailCategory = WriteRingSection<uint>(
frame,
_detailCategoryData.AsSpan(0, count));
GpuRingAllocation commands = WriteIndirectCommands(
frame,
_indirectCommands.AsSpan(0, count),
transformBaseInstance);
_alphaCommands = new RhiSection(
commands.Buffer,
commands.OffsetBytes,
checked((uint)(count * DrawCommandStride)));
}
/// <summary>
/// The push-constant/pipeline/storage-binding setup <see cref="DrawPreparedAlphaBatchRhi"/>'s
/// drain and <see cref="DrawImmediateAlphaInstanceRhi"/>'s single-
/// instance immediate draw (S4-c2 fix round 1, M3) share — the only
/// 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). The immediate site additionally arms detail on its original draw.
/// </summary>
private GpuPushConstants BindAlphaDrawState(
IGpuPassEncoder encoder,
IGpuFrame frame,
GlobalMeshBuffer mesh,
Matrix4x4 viewProjection,
out MeshPipelineSet pipelines)
{
var pushConstants = new GpuPushConstants
{
ViewProjection = viewProjection,
DrawIdOffset = 0,
LightingMode = 0,
RenderPass = 1,
LightDebug = RenderingDiagnostics.LightDebugMode,
TextureIndexA = 0,
TextureIndexB = _alphaTransformBaseInstance,
ParamA = 0f,
ParamB = 0f,
};
pipelines = PipelinesFor(
encoder,
frame,
out DirectionalShadowFrameBinding shadowBinding);
BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh);
encoder.SetPushConstants(in pushConstants);
BindDirectionalShadowReceiver(encoder, in shadowBinding);
BindSection(encoder, GpuBindingModel.StorageInstances, _alphaInstances);
BindSection(encoder, GpuBindingModel.StorageBatches, _alphaBatches);
BindSection(encoder, GpuBindingModel.StorageClipSlots, _alphaClipSlots);
BindSection(encoder, GpuBindingModel.StorageGlobalLights, _alphaGlobalLights);
BindSection(encoder, GpuBindingModel.StorageInstanceLightSets, _alphaLightSets);
BindSection(encoder, GpuBindingModel.StorageInstanceIndoor, _alphaIndoor);
BindSection(encoder, GpuBindingModel.StorageInstanceAlpha, _alphaOpacity);
BindSection(
encoder,
GpuBindingModel.StorageInstanceSelectionLighting,
_alphaSelectionLighting);
BindSection(
encoder,
GpuBindingModel.StorageInstanceDetailCategory,
_alphaDetailCategory);
AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions(
encoder, _scope!.Sections, frame);
AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting(
encoder, _scope!.Sections, frame);
return pushConstants;
}
/// <summary>
/// S4-c2 fix round 1 (M3c): delayed replay is ALWAYS <c>detailEnabled=0</c>
/// (OH1 contract §4/§5 — <c>FlushAlphaList</c>'s replay calls
/// <c>RenderMeshSubset(..., 0, entry.clip, ...)</c>) because every
/// detail-eligible subset now takes Row 1 immediate instead
/// (<c>SubmitToAlphaQueue</c>'s <c>detailSurfaceActive</c> gate) and
/// never reaches this drain at all — so this loop batches purely by
/// blend kind, with no per-command detail-category branch left.
/// </summary>
private void DrawPreparedAlphaBatchRhi(
GlobalMeshBuffer mesh,
int firstPreparedDraw,
int drawCount)
{
if (_alphaCommands.Buffer is null)
return;
IGpuPassEncoder encoder = _scope!.RequireEncoder();
IGpuFrame frame = RequireRhiFrame();
GpuPushConstants pushConstants = BindAlphaDrawState(
encoder, frame, mesh, _deferredAlphaViewProjection, out MeshPipelineSet pipelines);
if (firstPreparedDraw < 0
|| drawCount < 0
|| firstPreparedDraw > _preparedAlphaInstanceCount - drawCount)
{
throw new ArgumentOutOfRangeException(
nameof(firstPreparedDraw),
"The prepared-alpha draw range exceeds its uploaded instance/category payload.");
}
int runStart = firstPreparedDraw;
int preparedEnd = firstPreparedDraw + drawCount;
while (runStart < preparedEnd)
{
TranslucencyKind blend = _deferredAlphaKinds[runStart];
int runEnd = runStart + 1;
while (runEnd < preparedEnd && _deferredAlphaKinds[runEnd] == blend)
runEnd++;
// ApplyRetailBlend's three cases are three pipelines, including the
// inverse-alpha one GpuBlendMode.InverseAlpha was added for.
BindPipelineWithMesh(encoder, PipelineForBlend(pipelines, blend), mesh);
encoder.SetPushConstants(in pushConstants);
DrawIndirectRangeRhi(
encoder,
ref pushConstants,
_alphaCommands.Buffer!,
_alphaCommands.OffsetBytes,
runStart,
runEnd - runStart);
runStart = runEnd;
}
}
/// <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 — 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 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(
GlobalMeshBuffer mesh, TranslucencyKind blend, Matrix4x4 viewProjection)
{
if (_alphaCommands.Buffer is null)
return;
IGpuPassEncoder encoder = _scope!.RequireEncoder();
IGpuFrame frame = RequireRhiFrame();
GpuPushConstants pushConstants = BindAlphaDrawState(
encoder, frame, mesh, viewProjection, out MeshPipelineSet pipelines);
BindPipelineWithMesh(encoder, PipelineForBlend(pipelines, blend), mesh);
encoder.SetPushConstants(in pushConstants);
ArmBuildingDetail(ref pushConstants);
DrawIndirectRangeRhi(
encoder,
ref pushConstants,
_alphaCommands.Buffer!,
_alphaCommands.OffsetBytes,
startCommand: 0,
commandCount: 1);
ClearDetailPushConstants(ref pushConstants);
encoder.SetPushConstants(in pushConstants);
}
private void DrawImmediateTransparentRhi(
IGpuPassEncoder encoder,
GlobalMeshBuffer mesh,
MeshPipelineSet pipelines,
ref GpuPushConstants pushConstants,
IGpuBuffer commandBuffer,
uint commandBase,
ReadOnlySpan<uint> usedDetailCategories,
bool detailEnabled)
{
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(
_indirectCommands[runEnd],
usedDetailCategories)))
{
runEnd++;
}
BindPipelineWithMesh(
encoder,
PipelineForBlend(pipelines, blend),
mesh);
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 DrawDetailAwareRangeRhi(
IGpuPassEncoder encoder,
ref GpuPushConstants pushConstants,
IGpuBuffer commandBuffer,
uint commandBase,
int firstCommand,
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)
{
pushConstants.TextureIndexA = _buildingDetail.TextureSlot.Index;
pushConstants.ParamA = _buildingDetail.Tiling;
}
private static void ClearDetailPushConstants(
ref GpuPushConstants pushConstants)
{
pushConstants.TextureIndexA = 0;
pushConstants.ParamA = 0f;
pushConstants.ParamB = 0f;
}
private static IGpuPipeline PipelineForBlend(MeshPipelineSet pipelines, TranslucencyKind blend) =>
blend switch
{
TranslucencyKind.Additive => pipelines.AlphaAdditive,
TranslucencyKind.InvAlpha => pipelines.AlphaInverse,
_ => pipelines.AlphaBlend,
};
/// <summary>
/// Reads cull modes from <paramref name="cullModes"/> when the caller
/// supplies one, or from the shared <see cref="_drawCullModes"/> scratch
/// otherwise (every pre-FW3.2a call site). Campaign FW stage FW3.2a:
/// <see cref="SubmitOrderedStream"/> passes its OWN scratch
/// (<see cref="_orderedDrawCullModes"/>) so a walk-ordered submission can
/// interleave with a mid-flight <see cref="RetailAlphaQueue"/> scope —
/// <c>_drawCullModes</c> is written fresh by
/// <c>PrepareDeferredAlphaDraws</c> at every alpha flush and read right
/// back by this method for that draw; an ordered submission sharing the
/// same array between those two steps could silently draw the alpha
/// content's faces with the ordered content's cull mode, or vice
/// versa (the FW2 caveat this stage retires — see
/// <c>WbDrawDispatcher.OrderedStream.cs</c>).
/// </summary>
private void DrawIndirectRangeRhi(
IGpuPassEncoder encoder,
ref GpuPushConstants pushConstants,
IGpuBuffer commandBuffer,
uint commandBaseOffsetBytes,
int startCommand,
int commandCount,
CullMode[]? cullModes = null)
{
CullMode[] modes = cullModes ?? _drawCullModes;
int end = startCommand + commandCount;
int command = startCommand;
while (command < end)
{
CullMode cullMode = modes[command];
ApplyCullModeRhi(encoder, cullMode);
int runCount = 1;
while (command + runCount < end && modes[command + runCount] == cullMode)
runCount++;
// Each multi-draw-indirect call restarts gl_DrawID at 0, so a run
// that begins partway into the batch array must carry its absolute
// command index or it reads BatchData[0] again (issue #52).
pushConstants.DrawIdOffset = command;
encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect(
commandBuffer,
commandBaseOffsetBytes + (uint)(command * DrawCommandStride),
(uint)runCount,
(uint)DrawCommandStride);
command += runCount;
}
}
/// <summary>
/// WB <c>BaseObjectRenderManager.cs:850-866</c> applies CullMode per MDI
/// group and WB <c>GameScene.cs:843</c> sets FrontFace(CW) globally. Both are
/// dynamic state in core Vulkan 1.3, and both must be re-issued after every
/// <c>BindPipeline</c>, which restores the pipeline's own defaults.
/// </summary>
private static void ApplyCullModeRhi(IGpuPassEncoder encoder, CullMode mode)
{
encoder.SetFrontFace(GpuFrontFace.Clockwise);
switch (mode)
{
case CullMode.None:
encoder.SetCullMode(GpuCullMode.None);
break;
case CullMode.Clockwise:
encoder.SetCullMode(GpuCullMode.Front);
break;
case CullMode.CounterClockwise:
case CullMode.Landblock:
encoder.SetCullMode(GpuCullMode.Back);
break;
}
}
/// <summary>
/// Binds a pipeline and immediately re-establishes the mesh source. Every
/// pipeline owns its own vertex array, and vertex attribute pointers plus the
/// index binding are vertex-array state, so a pipeline switch inside a pass
/// silently drops them while storage bindings survive.
/// </summary>
private static void BindPipelineWithMesh(
IGpuPassEncoder encoder,
IGpuPipeline pipeline,
GlobalMeshBuffer mesh)
{
encoder.BindPipeline(pipeline);
encoder.BindVertexBuffer(
0,
mesh.VertexStore ?? throw new InvalidOperationException(
"The shared mesh arena has no vertex store."),
0);
encoder.BindIndexBuffer(
mesh.IndexStore ?? throw new InvalidOperationException(
"The shared mesh arena has no index store."),
0,
GpuIndexType.UInt16);
}
private void BindGlobalLightsRhi(IGpuPassEncoder encoder, IGpuFrame frame)
{
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int uploadCount = lightCount > 0 ? lightCount : 1;
BindRingSection<float>(
encoder,
frame,
GpuBindingModel.StorageGlobalLights,
_globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight));
}
private static void BindRingSection<T>(
IGpuPassEncoder encoder,
IGpuFrame frame,
uint binding,
ReadOnlySpan<T> data)
where T : unmanaged =>
BindSection(encoder, binding, WriteRingSection(frame, data));
private static void BindSection(
IGpuPassEncoder encoder,
uint binding,
in RhiSection section)
{
if (section.Buffer is null)
return;
encoder.BindStorageBuffer(
binding,
section.Buffer,
section.OffsetBytes,
section.SizeBytes);
}
private static RhiSection WriteRingSection<T>(
IGpuFrame frame,
ReadOnlySpan<T> data,
GpuRingUsage usage = GpuRingUsage.Storage)
where T : unmanaged
{
int elementBytes = sizeof(T);
int byteCount = Math.Max(data.Length * elementBytes, elementBytes);
GpuRingAllocation allocation = frame.AllocateRing(byteCount, usage);
if (!data.IsEmpty)
data.CopyTo(allocation.AsSpan<T>());
return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount);
}
/// <summary>
/// #443 — the next classic draw is a PRIVATE pass (paperdoll, appraisal,
/// chargen preview) and must take the plain ring transform path with
/// <c>firstInstance = 0</c>, never an append into the shared world
/// transform frame. The default mesh shaders index every parallel
/// per-instance array (clip slots, light sets, indoor, opacity, selection,
/// detail category) zero-based — only the packed world submission's
/// shader convention subtracts a shared-arena prefix — so an
/// arena-appended classic draw with a non-zero base reads zeroed
/// per-instance data (opacity 0 ⇒ an invisible doll whenever a world
/// frame is active; portal space worked only because the arena was
/// inactive there). The private pass owns its own camera, lighting, and
/// target; per the self-contained-render-state rule it must not depend on
/// the world frame's pose address space at all. Consumed and cleared by
/// the next <see cref="WriteWorldTransformSection"/>.
/// </summary>
internal bool NextClassicDrawIsPrivatePass;
private RhiSection WriteWorldTransformSection(
IGpuFrame frame,
ReadOnlySpan<float> matrixFloats,
out uint firstInstance)
{
ResetWorldTransformFrameIfStale(frame.Serial);
if ((matrixFloats.Length & 15) != 0)
{
throw new ArgumentException(
"World transforms must contain complete 16-float matrices.",
nameof(matrixFloats));
}
ObserveOrdinaryTransformDemand(
frame.Serial,
checked((uint)(matrixFloats.Length / 16)));
bool privatePass = NextClassicDrawIsPrivatePass;
NextClassicDrawIsPrivatePass = false;
if (privatePass || !_worldTransformFrames.IsActive)
{
firstInstance = 0;
return WriteRingSection(frame, matrixFloats);
}
WorldTransformFrameSlice appended = _worldTransformFrames.Append(
frame,
MemoryMarshal.Cast<float, Matrix4x4>(matrixFloats));
firstInstance = appended.FirstInstance;
return new RhiSection(
appended.Buffer,
appended.BaseOffsetBytes,
appended.BindingSizeBytes);
}
private static GpuRingAllocation WriteIndirectCommands(
IGpuFrame frame,
Span<DrawElementsIndirectCommand> commands,
uint baseInstance)
{
int byteCount = checked(commands.Length * DrawCommandStride);
GpuRingAllocation allocation = frame.AllocateRing(
byteCount,
GpuRingUsage.Indirect);
if (baseInstance == 0)
{
MemoryMarshal.AsBytes(commands).CopyTo(allocation.Data);
return allocation;
}
int adjusted = 0;
try
{
for (int i = 0; i < commands.Length; i++)
{
commands[i].BaseInstance = checked(
commands[i].BaseInstance + baseInstance);
adjusted++;
}
MemoryMarshal.AsBytes(commands).CopyTo(allocation.Data);
}
finally
{
for (int i = 0; i < adjusted; i++)
commands[i].BaseInstance -= baseInstance;
}
return allocation;
}
private void ResetWorldTransformFrameIfStale(long frameSerial)
{
_worldTransformFrames.ResetIfStale(frameSerial);
}
private void ObserveOrdinaryTransformDemand(long frameSerial, uint instances)
{
if (_ordinaryTransformDemandFrameSerial != frameSerial)
{
_ordinaryTransformDemandFrameSerial = frameSerial;
_ordinaryTransformDemandThisFrame = 0;
}
_ordinaryTransformDemandThisFrame = checked(
_ordinaryTransformDemandThisFrame + instances);
_ordinaryTransformDemandHighWater = Math.Max(
_ordinaryTransformDemandHighWater,
_ordinaryTransformDemandThisFrame);
}
private void ResetWorldTransformFrame()
{
_worldTransformFrames.Reset();
}
private IGpuFrame RequireRhiFrame()
{
// The same precondition ActivateNextDynamicBufferSet enforces on GL: a
// draw that has not been bracketed by BeginFrame has no slot to write to.
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing world entities.");
return _frames!.CurrentFrame
?? throw new InvalidOperationException(
"WbDrawDispatcher requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
}
private static IDisposable BeginRhiTimer(
IGpuPassEncoder encoder,
bool diag,
string scopeName) =>
diag ? encoder.BeginTimerScope(scopeName) : NullRhiTimerScope.Instance;
/// <summary>
/// The [WB-DIAG] median/p95 window still measures opaque + transparent GPU
/// time; the sample now comes from the device's timer pool — the most recent
/// retired result — rather than a hand-rolled 3-deep query ring read at N-3.
/// A sample can therefore repeat when the GPU has not finished a newer query,
/// where the old code dropped it. Diagnostic-only.
/// </summary>
private void SampleRhiTimers(bool diag)
{
if (!diag || _device is null)
return;
double totalMs = 0;
bool any = false;
if (_device.Timers.TryResolve(OpaqueTimerScope, out double opaqueMs))
{
totalMs += opaqueMs;
any = true;
}
if (_device.Timers.TryResolve(TransparentTimerScope, out double transparentMs))
{
totalMs += transparentMs;
any = true;
}
if (!any)
return;
_gpuSamples[_gpuSampleCursor] = (long)(totalMs * 1000.0);
_gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length;
}
private void DisposeRhiResources()
{
MeshPipelineSet? backbuffer = _backbufferPipelines;
MeshPipelineSet? offscreen = _offscreenPipelines;
_backbufferPipelines = null;
_offscreenPipelines = null;
DisposeMeshPipelineSet(backbuffer);
// Reference-equal when the backbuffer is single-sampled, in which case
// there is one set and disposing it twice would be a double free.
if (!ReferenceEquals(offscreen, backbuffer))
DisposeMeshPipelineSet(offscreen);
DisposeDirectionalShadowReceiverPipelines();
}
private static void DisposeMeshPipelineSet(MeshPipelineSet? pipelines)
{
if (pipelines is null)
return;
pipelines.Opaque.Dispose();
pipelines.OpaqueAlphaToCoverage.Dispose();
pipelines.AlphaBlend.Dispose();
pipelines.AlphaAdditive.Dispose();
pipelines.AlphaInverse.Dispose();
}
private sealed class NullRhiTimerScope : IDisposable
{
internal static NullRhiTimerScope Instance { get; } = new();
public void Dispose()
{
}
}
}