feat(render): implement Campaign AR and terrain fidelity

This commit is contained in:
Erik 2026-08-22 13:13:29 +02:00
parent 99cf26e00c
commit 7a5f96ede5
368 changed files with 50611 additions and 950 deletions

View file

@ -16,8 +16,8 @@ namespace AcDream.App.Rendering.Wb;
/// <para>Two structural differences from V4c. It records into the pass
/// <c>VulkanWorldScenePhase</c> opened rather than opening
/// <c>"envcell-shells"</c> of its own, because the frame's one backbuffer pass
/// resolves. And there is no binding-9 texture table: V4t moved the slot onto
/// the device, and on Vulkan that table is set 2, which the encoder binds.</para>
/// resolves. And the global texture table is set 2 rather than a storage
/// buffer: storage binding 9 is now #226's per-instance detail category.</para>
/// </summary>
public sealed unsafe partial class EnvCellRenderer
{
@ -27,10 +27,14 @@ public sealed unsafe partial class EnvCellRenderer
private IGpuPipeline? _opaquePipeline;
private IGpuPipeline? _alphaPipeline;
private IGpuPipeline? _additivePipeline;
private IGpuPipeline? _detailPipeline;
private IGpuPipeline? _transparentDetailPipeline;
private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail;
private readonly Func<bool> _buildingDetailEnabled;
/// <summary>
/// The RHI arm's constructor. It also completes <c>Initialize</c>'s job: the
/// three pipelines ARE this renderer's program, so there is no second step
/// five pipelines ARE this renderer's program, so there is no second step
/// and no <c>Shader</c> to hand in.
/// </summary>
internal EnvCellRenderer(
@ -38,13 +42,17 @@ public sealed unsafe partial class EnvCellRenderer
ICurrentGpuFrameSource frames,
IWorldPassScope scope,
ObjectMeshManager meshManager,
WbFrustum frustum)
WbFrustum frustum,
TerrainAtlas.RetailDetailTextureBinding environmentDetail = default,
Func<bool>? buildingDetailEnabled = null)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
_meshManager = meshManager ?? throw new ArgumentNullException(nameof(meshManager));
_frustum = frustum ?? throw new ArgumentNullException(nameof(frustum));
_environmentDetail = environmentDetail;
_buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures;
_opaquePipeline = CreateShellPipeline(
device, "envcell-opaque", GpuBlendMode.None, depthWrite: true, scope.SampleCount);
@ -52,9 +60,29 @@ public sealed unsafe partial class EnvCellRenderer
device, "envcell-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, 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));
_initialized = true;
}
private static bool DisableDetailTextures() => false;
/// <summary>
/// One pipeline per blend state the shell pass uses. Everything else is
/// shared: <c>mesh_modern</c>, the 32-byte world-mesh vertex, triangle lists,
@ -70,15 +98,17 @@ public sealed unsafe partial class EnvCellRenderer
string name,
GpuBlendMode blend,
bool depthWrite,
int sampleCount) =>
int sampleCount,
string shaderName = "mesh_modern",
GpuCompareOp depthCompare = GpuCompareOp.Less) =>
device.CreatePipeline(new GpuPipelineDescription
{
Name = name,
Shaders = new GpuShaderSet("mesh_modern"),
Shaders = new GpuShaderSet(shaderName),
VertexLayout = GpuVertexLayout.WorldMesh,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = blend,
Depth = new GpuDepthState(Test: true, Write: depthWrite, GpuCompareOp.Less),
Depth = new GpuDepthState(Test: true, Write: depthWrite, depthCompare),
Cull = GpuCullMode.Back,
FrontFace = GpuFrontFace.Clockwise,
AlphaToCoverage = false,
@ -196,6 +226,7 @@ public sealed unsafe partial class EnvCellRenderer
BindRingSection<int>(
encoder, frame, GpuBindingModel.StorageInstanceLightSets,
_lightSetData.AsSpan(0, uniqueInstanceCount * lightStride));
BindEnvironmentDetailCategory(encoder, frame);
// The frame-global sections, bound after this renderer's own binds
// because those binds are what select the descriptor scope.
@ -210,6 +241,9 @@ 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);
for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++)
{
@ -222,13 +256,16 @@ public sealed unsafe partial class EnvCellRenderer
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
bool isAdditive = groupIndex >= 4;
IGpuPipeline rangeBasePipeline = isAdditive
? _additivePipeline!
: _alphaPipeline!;
if (renderPass == WbRenderPass.Transparent)
{
// Blend state is the pipeline's; switching variants mid-pass has
// to re-establish the mesh, which is vertex-array state.
BindPipelineWithMesh(
encoder,
isAdditive ? _additivePipeline! : _alphaPipeline!,
rangeBasePipeline,
mesh);
}
@ -241,12 +278,85 @@ public sealed unsafe partial class EnvCellRenderer
: (int)renderPass;
pushConstants.DrawIdOffset = drawRange.FirstCommand;
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 = 0f;
encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect(
commandBuffer,
commandBase + (uint)(command * sizeof(DrawElementsIndirectCommand)),
1,
(uint)sizeof(DrawElementsIndirectCommand));
BindPipelineWithMesh(encoder, _transparentDetailPipeline!, mesh);
SetCullMode(encoder, cullMode);
pushConstants.DrawIdOffset = command;
pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index;
pushConstants.ParamA = _environmentDetail.Tiling;
pushConstants.ParamB = 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)),
(uint)drawRange.CommandCount,
(uint)sizeof(DrawElementsIndirectCommand));
}
// Retail DrawEnvCell category (2). Replay the already-filtered opaque
// shell commands, including ClipMap built-mesh subsets, and apply the
// 10-50 m positive-view-depth fade. 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];
var cullMode = (CullMode)(drawRange.GroupIndex % 4);
if (cullMode == CullMode.Landblock)
cullMode = CullMode.None;
SetCullMode(encoder, cullMode);
pushConstants.DrawIdOffset = drawRange.FirstCommand;
encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect(
commandBuffer,
commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)),
(uint)drawRange.CommandCount,
(uint)sizeof(DrawElementsIndirectCommand));
}
}
}
private void BindPipelineWithMesh(
@ -315,6 +425,25 @@ public sealed unsafe partial class EnvCellRenderer
(uint)byteCount);
}
/// <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.
/// </summary>
internal static void BindEnvironmentDetailCategory(
IGpuPassEncoder encoder,
IGpuFrame frame)
{
Span<uint> category = stackalloc uint[1];
category[0] = 1u;
BindRingSection<uint>(
encoder,
frame,
GpuBindingModel.StorageInstanceDetailCategory,
category);
}
private void DisposeRhiResources()
{
_opaquePipeline?.Dispose();
@ -323,5 +452,9 @@ public sealed unsafe partial class EnvCellRenderer
_alphaPipeline = null;
_additivePipeline?.Dispose();
_additivePipeline = null;
_detailPipeline?.Dispose();
_detailPipeline = null;
_transparentDetailPipeline?.Dispose();
_transparentDetailPipeline = null;
}
}

View file

@ -1290,6 +1290,11 @@ public sealed partial class EnvCellRenderer :
// mesh manager at upload rather than interned here.
TextureTableIndex = item.batch.TextureSlot.Index,
TextureIndex = (uint)item.batch.TextureIndex,
// #226: this renderer submits built EnvCell meshes.
// Retail DrawMesh forwards curr_detail_surface to
// RenderMeshSubset for every material subset, including
// ClipMap, transparent, additive and inverse alpha.
Flags = 1u,
};
_commands[cmdIndex] = new DrawElementsIndirectCommand

View file

@ -0,0 +1,130 @@
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering.Wb;
public sealed partial class WbDrawDispatcher
{
internal sealed class DirectionalShadowReceiverPipelineState : IDisposable
{
private readonly MeshPipelineSet _backbuffer;
private readonly MeshPipelineSet _offscreen;
internal DirectionalShadowReceiverPipelineState(
IDirectionalShadowReceiverSource source,
MeshPipelineSet backbuffer,
MeshPipelineSet offscreen)
{
Source = source;
_backbuffer = backbuffer;
_offscreen = offscreen;
}
internal IDirectionalShadowReceiverSource Source { get; }
internal MeshPipelineSet ForSampleCount(int sampleCount) =>
sampleCount > 1 ? _backbuffer : _offscreen;
public void Dispose()
{
DisposeMeshPipelineSet(_backbuffer);
if (!ReferenceEquals(_offscreen, _backbuffer))
DisposeMeshPipelineSet(_offscreen);
}
}
private DirectionalShadowReceiverPipelineState? _directionalShadowReceiver;
/// <summary>
/// Builds both sample-count variants without publishing either. A different
/// pack always gets pipelines compiled from that candidate's shader blobs;
/// no prior pack pipeline is reused by shader name or nullable caching.
/// </summary>
internal DirectionalShadowReceiverPipelineState? PrepareDirectionalShadowReceiver(
IDirectionalShadowReceiverSource? source,
int sampleCount)
{
if (source is null)
return null;
IGpuDevice device = _device
?? throw new InvalidOperationException("Directional receivers require the modern RHI device.");
if (_scope is null || sampleCount != _scope.SampleCount)
throw new InvalidOperationException("Receiver and world-pass sample counts must match.");
MeshPipelineSet? backbuffer = null;
MeshPipelineSet? offscreen = null;
try
{
backbuffer = CreateMeshPipelineSet(
device,
sampleCount,
baseShaders: source.PipelineShaders.WorldReceiver,
namePrefix: "wb-mesh-atmospheric",
usesRenderPackShaderAbi: true);
offscreen = sampleCount == 1
? backbuffer
: CreateMeshPipelineSet(
device,
1,
baseShaders: source.PipelineShaders.WorldReceiver,
namePrefix: "wb-mesh-atmospheric",
usesRenderPackShaderAbi: true);
return new DirectionalShadowReceiverPipelineState(source, backbuffer, offscreen);
}
catch
{
DisposeMeshPipelineSet(backbuffer);
if (!ReferenceEquals(offscreen, backbuffer))
DisposeMeshPipelineSet(offscreen);
throw;
}
}
/// <summary>Assignment-only publication; returned state retires after the coupled swap.</summary>
internal DirectionalShadowReceiverPipelineState? SwapDirectionalShadowReceiver(
DirectionalShadowReceiverPipelineState? candidate)
{
DirectionalShadowReceiverPipelineState? prior = _directionalShadowReceiver;
_directionalShadowReceiver = candidate;
return prior;
}
private MeshPipelineSet PipelinesFor(
IGpuPassEncoder encoder,
IGpuFrame frame,
out DirectionalShadowFrameBinding shadowBinding)
{
DirectionalShadowReceiverPipelineState? receiver = _directionalShadowReceiver;
IDirectionalShadowReceiverSource? source = receiver?.Source;
shadowBinding = DirectionalShadowFrameBinding.Disabled;
bool bindingValid = source is not null
&& source.TryGetCurrentFrameBinding(frame, out shadowBinding);
if (DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline(
encoder.Pass.Name,
source is not null,
bindingValid))
{
return receiver!.ForSampleCount(encoder.Pass.SampleCount);
}
return PipelinesFor(encoder);
}
private static void BindDirectionalShadowReceiver(
IGpuPassEncoder encoder,
in DirectionalShadowFrameBinding binding)
{
if (!binding.Enabled || binding.Buffer is null)
return;
encoder.BindUniformBuffer(
GpuBindingModel.UniformDirectionalShadow,
binding.Buffer,
binding.OffsetBytes,
binding.SizeBytes);
}
private void DisposeDirectionalShadowReceiverPipelines()
{
DirectionalShadowReceiverPipelineState? state =
SwapDirectionalShadowReceiver(null);
state?.Dispose();
}
}

File diff suppressed because it is too large Load diff

View file

@ -636,6 +636,7 @@ public sealed unsafe partial class WbDrawDispatcher
slot,
lights,
indoor,
entity.IsBuildingShell,
opacity,
selectionLighting);
}
@ -661,6 +662,7 @@ public sealed unsafe partial class WbDrawDispatcher
slot,
lights,
indoor,
entity.IsBuildingShell,
opacity: 1f,
selectionLighting);
}
@ -684,19 +686,51 @@ public sealed unsafe partial class WbDrawDispatcher
uint slot,
InstanceLightSet lights,
bool indoor,
bool buildingDetail,
float opacity,
Vector2 selectionLighting)
{
InstanceGroup group =
GetOrCreatePackedGroup(classified.Key);
AppendPackedInstance(
group,
model,
classified.LocalSortCenter,
_nextPackedInstanceSubmissionOrder++,
slot,
lights,
indoor,
buildingDetail,
opacity,
selectionLighting);
}
/// <summary>
/// Appends one packed-route instance and every per-instance attribute in
/// lockstep. Keeping the writer in one testable seam prevents a newly
/// introduced storage binding from covering the legacy classifier while
/// leaving the production packed classifier with a shorter parallel list.
/// </summary>
internal static void AppendPackedInstance(
InstanceGroup group,
Matrix4x4 model,
Vector3 localSortCenter,
int submissionOrder,
uint slot,
InstanceLightSet lights,
bool indoor,
bool buildingDetail,
float opacity,
Vector2 selectionLighting)
{
ArgumentNullException.ThrowIfNull(group);
group.Matrices.Add(model);
group.LocalSortCenters.Add(
classified.LocalSortCenter);
group.SubmissionOrders.Add(
_nextPackedInstanceSubmissionOrder++);
group.LocalSortCenters.Add(localSortCenter);
group.SubmissionOrders.Add(submissionOrder);
group.Slots.Add(slot);
group.LightSets.Add(lights);
group.IndoorFlags.Add(indoor ? 1u : 0u);
group.DetailCategories.Add(buildingDetail ? 1u : 0u);
group.Opacities.Add(opacity);
group.SelectionLighting.Add(selectionLighting);
}

View file

@ -18,9 +18,9 @@ namespace AcDream.App.Rendering.Wb;
/// 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. There
/// is no binding-9 texture table — V4t put the slot on the device and Vulkan
/// binds set 2. The pass is BORROWED from <see cref="IWorldPassScope"/> rather
/// <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
@ -33,7 +33,7 @@ public sealed unsafe partial class WbDrawDispatcher
private readonly IWorldPassScope? _scope;
/// <summary>
/// The five mesh pipelines at ONE sample count.
/// The seven mesh 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
@ -46,19 +46,25 @@ public sealed unsafe partial class WbDrawDispatcher
/// the live pass actually is. Both are built at startup against the persisted
/// cache, so no frame ever compiles one.</para>
/// </summary>
private sealed record MeshPipelineSet(
internal sealed record MeshPipelineSet(
int SampleCount,
IGpuPipeline Opaque,
IGpuPipeline OpaqueAlphaToCoverage,
IGpuPipeline AlphaBlend,
IGpuPipeline AlphaAdditive,
IGpuPipeline AlphaInverse);
IGpuPipeline AlphaInverse,
IGpuPipeline RetailDetail,
IGpuPipeline RetailDetailTransparent);
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;
/// <summary>
/// A ring slice reduced to the three values a later bind needs. The prepared
@ -70,6 +76,11 @@ public sealed unsafe partial class WbDrawDispatcher
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;
@ -78,12 +89,78 @@ public sealed unsafe partial class WbDrawDispatcher
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>: the five pipelines compile <c>mesh_modern</c> from
/// the committed SPIR-V, and batch data already carries the device's own
/// <c>BindlessSupport</c>: five base pipelines compile <c>mesh_modern</c> and
/// the detail overlay compiles <c>mesh_detail</c> from committed SPIR-V;
/// batch data already carries the device's own
/// <c>GpuTextureSlot</c> (V4t) rather than a bindless handle.
/// </summary>
internal WbDrawDispatcher(
@ -97,7 +174,9 @@ public sealed unsafe partial class WbDrawDispatcher
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades,
IRetailSelectionRenderSink? selectionSink = null,
RetailAlphaQueue? alphaQueue = null,
long? alphaScratchBudgetBytes = null)
long? alphaScratchBudgetBytes = null,
TerrainAtlas.RetailDetailTextureBinding buildingDetail = default,
Func<bool>? buildingDetailEnabled = null)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
@ -114,6 +193,8 @@ public sealed unsafe partial class WbDrawDispatcher
_selectionLighting = selectionSink as IRetailSelectionLightingSource;
_alphaQueue = alphaQueue;
_alphaSource = new AlphaDrawSource(this);
_buildingDetail = buildingDetail;
_buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures;
long scratchBudget = alphaScratchBudgetBytes
?? AlphaScratchBudgetProfile.Create(
ResidencyBudgetOptions.Default.AlphaScratchBytes)
@ -137,21 +218,82 @@ public sealed unsafe partial class WbDrawDispatcher
}
}
private static MeshPipelineSet CreateMeshPipelineSet(IGpuDevice device, int samples)
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";
return new MeshPipelineSet(
samples,
CreateMeshPipeline(
device, $"wb-mesh-opaque{suffix}", GpuBlendMode.None, true, false, samples),
CreateMeshPipeline(
device, $"wb-mesh-opaque-a2c{suffix}", GpuBlendMode.None, true, true, samples),
CreateMeshPipeline(
device, $"wb-mesh-alpha{suffix}", GpuBlendMode.StraightAlpha, false, false, samples),
CreateMeshPipeline(
device, $"wb-mesh-additive{suffix}", GpuBlendMode.Additive, false, false, samples),
CreateMeshPipeline(
device, $"wb-mesh-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples));
var created = new List<IGpuPipeline>(7);
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)),
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
{
for (int i = created.Count - 1; i >= 0; i--)
created[i].Dispose();
throw;
}
IGpuPipeline Track(IGpuPipeline pipeline)
{
created.Add(pipeline);
return pipeline;
}
}
/// <summary>
@ -183,19 +325,24 @@ public sealed unsafe partial class WbDrawDispatcher
GpuBlendMode blend,
bool depthWrite,
bool alphaToCoverage,
int sampleCount) =>
int sampleCount,
string shaderName = "mesh_modern",
GpuShaderSet? shaders = null,
GpuCompareOp depthCompare = GpuCompareOp.Less,
bool usesRenderPackShaderAbi = false) =>
device.CreatePipeline(new GpuPipelineDescription
{
Name = name,
Shaders = new GpuShaderSet("mesh_modern"),
Shaders = shaders ?? new GpuShaderSet(shaderName),
VertexLayout = GpuVertexLayout.WorldMesh,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = blend,
Depth = new GpuDepthState(Test: true, Write: depthWrite, GpuCompareOp.Less),
Depth = new GpuDepthState(Test: true, Write: depthWrite, depthCompare),
Cull = GpuCullMode.Back,
FrontFace = GpuFrontFace.Clockwise,
AlphaToCoverage = alphaToCoverage,
ColorWrite = true,
UsesRenderPackShaderAbi = usesRenderPackShaderAbi,
SampleCount = sampleCount,
});
@ -230,19 +377,33 @@ public sealed unsafe partial class WbDrawDispatcher
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);
MeshPipelineSet pipelines = PipelinesFor(
encoder,
frame,
out DirectionalShadowFrameBinding shadowBinding);
BindPipelineWithMesh(
encoder,
AlphaToCoverage ? pipelines.OpaqueAlphaToCoverage : pipelines.Opaque,
mesh);
encoder.SetPushConstants(in pushConstants);
BindDirectionalShadowReceiver(encoder, in shadowBinding);
BindRingSection<float>(
encoder, frame, GpuBindingModel.StorageInstances,
_instanceData.AsSpan(0, immediateInstances * 16));
BindSection(
encoder,
GpuBindingModel.StorageInstances,
instanceTransforms);
BindRingSection<BatchData>(
encoder, frame, GpuBindingModel.StorageBatches,
_batchData.AsSpan(0, totalDraws));
@ -262,19 +423,25 @@ public sealed unsafe partial class WbDrawDispatcher
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 = frame.AllocateRing(
totalDraws * DrawCommandStride,
GpuRingUsage.Indirect);
MemoryMarshal.AsBytes(_indirectCommands.AsSpan(0, totalDraws))
.CopyTo(commands.Data);
GpuRingAllocation commands = WriteIndirectCommands(
frame,
_indirectCommands.AsSpan(0, totalDraws),
transformBaseInstance);
IGpuBuffer commandBuffer = commands.Buffer;
uint commandBase = commands.OffsetBytes;
ReadOnlySpan<DrawElementsIndirectCommand> usedCommands =
_indirectCommands.AsSpan(0, totalDraws);
ReadOnlySpan<uint> usedDetailCategories =
_detailCategoryData.AsSpan(0, immediateInstances);
// ── Phase 7: opaque pass ─────────────────────────────────────────────
if (_opaqueDrawCount > 0)
@ -295,10 +462,64 @@ public sealed unsafe partial class WbDrawDispatcher
}
}
// 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)
{
BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh);
// 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.
@ -307,9 +528,14 @@ public sealed unsafe partial class WbDrawDispatcher
encoder.SetPushConstants(in pushConstants);
using (BeginRhiTimer(encoder, diag, TransparentTimerScope))
{
DrawIndirectRangeRhi(
encoder, ref pushConstants, commandBuffer, commandBase,
_opaqueDrawCount, _transparentDrawCount);
DrawImmediateTransparentRhi(
encoder,
mesh,
pipelines,
ref pushConstants,
commandBuffer,
commandBase,
usedDetailCategories);
}
}
@ -323,8 +549,13 @@ public sealed unsafe partial class WbDrawDispatcher
/// </summary>
private void PrepareRhiAlphaSections(int count)
{
_preparedAlphaInstanceCount = count;
IGpuFrame frame = RequireRhiFrame();
_alphaInstances = WriteRingSection<float>(frame, _instanceData.AsSpan(0, count * 16));
_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);
@ -340,10 +571,17 @@ public sealed unsafe partial class WbDrawDispatcher
_alphaSelectionLighting = WriteRingSection<Vector2>(
frame,
_selectionLightingData.AsSpan(0, count));
_alphaCommands = WriteRingSection<DrawElementsIndirectCommand>(
_alphaDetailCategory = WriteRingSection<uint>(
frame,
_detailCategoryData.AsSpan(0, count));
GpuRingAllocation commands = WriteIndirectCommands(
frame,
_indirectCommands.AsSpan(0, count),
GpuRingUsage.Indirect);
transformBaseInstance);
_alphaCommands = new RhiSection(
commands.Buffer,
commands.OffsetBytes,
checked((uint)(count * DrawCommandStride)));
}
private void DrawPreparedAlphaBatchRhi(
@ -366,14 +604,18 @@ public sealed unsafe partial class WbDrawDispatcher
RenderPass = 1,
LightDebug = RenderingDiagnostics.LightDebugMode,
TextureIndexA = 0,
TextureIndexB = 0,
TextureIndexB = _alphaTransformBaseInstance,
ParamA = 0f,
ParamB = 0f,
};
MeshPipelineSet pipelines = PipelinesFor(encoder);
MeshPipelineSet 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);
@ -385,18 +627,45 @@ public sealed unsafe partial class WbDrawDispatcher
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);
bool detailEnabled = RetailDetailTextureContract.ShouldRender(
_buildingDetailEnabled(),
_buildingDetail);
if (firstPreparedDraw < 0
|| drawCount < 0
|| firstPreparedDraw > _preparedAlphaInstanceCount - drawCount)
{
throw new ArgumentOutOfRangeException(
nameof(firstPreparedDraw),
"The prepared-alpha draw range exceeds its uploaded instance/category payload.");
}
ReadOnlySpan<uint> usedDetailCategories =
_detailCategoryData.AsSpan(0, _preparedAlphaInstanceCount);
int runStart = firstPreparedDraw;
int preparedEnd = firstPreparedDraw + drawCount;
while (runStart < preparedEnd)
{
TranslucencyKind blend = _deferredAlphaKinds[runStart];
bool hasDetail = detailEnabled
&& CommandContainsDetailCategory(
_indirectCommands[runStart],
usedDetailCategories);
int runEnd = runStart + 1;
while (runEnd < preparedEnd && _deferredAlphaKinds[runEnd] == blend)
while (runEnd < preparedEnd
&& !hasDetail
&& _deferredAlphaKinds[runEnd] == blend
&& (!detailEnabled
|| !CommandContainsDetailCategory(
_indirectCommands[runEnd],
usedDetailCategories)))
runEnd++;
// ApplyRetailBlend's three cases are three pipelines, including the
@ -410,10 +679,112 @@ public sealed unsafe partial class WbDrawDispatcher
_alphaCommands.OffsetBytes,
runStart,
runEnd - runStart);
if (hasDetail)
{
DrawBuildingDetailRangeRhi(
encoder,
mesh,
pipelines.RetailDetailTransparent,
ref pushConstants,
_alphaCommands.Buffer!,
_alphaCommands.OffsetBytes,
runStart,
1);
}
runStart = runEnd;
}
}
private void DrawImmediateTransparentRhi(
IGpuPassEncoder encoder,
GlobalMeshBuffer mesh,
MeshPipelineSet pipelines,
ref GpuPushConstants pushConstants,
IGpuBuffer commandBuffer,
uint commandBase,
ReadOnlySpan<uint> usedDetailCategories)
{
bool detailEnabled = RetailDetailTextureContract.ShouldRender(
_buildingDetailEnabled(),
_buildingDetail);
int command = _opaqueDrawCount;
int end = command + _transparentDrawCount;
while (command < end)
{
bool hasDetail = detailEnabled
&& CommandContainsDetailCategory(
_indirectCommands[command],
usedDetailCategories);
int runEnd = command + 1;
while (runEnd < end
&& !hasDetail
&& (!detailEnabled
|| !CommandContainsDetailCategory(
_indirectCommands[runEnd],
usedDetailCategories)))
{
runEnd++;
}
BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh);
ClearDetailPushConstants(ref pushConstants);
DrawIndirectRangeRhi(
encoder,
ref pushConstants,
commandBuffer,
commandBase,
command,
runEnd - command);
if (hasDetail)
{
DrawBuildingDetailRangeRhi(
encoder,
mesh,
pipelines.RetailDetailTransparent,
ref pushConstants,
commandBuffer,
commandBase,
command,
1);
}
command = runEnd;
}
}
private void DrawBuildingDetailRangeRhi(
IGpuPassEncoder encoder,
GlobalMeshBuffer mesh,
IGpuPipeline detailPipeline,
ref GpuPushConstants pushConstants,
IGpuBuffer commandBuffer,
uint commandBase,
int firstCommand,
int commandCount)
{
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(
ref GpuPushConstants pushConstants)
{
pushConstants.TextureIndexA = 0;
pushConstants.ParamA = 0f;
pushConstants.ParamB = 0f;
}
private static IGpuPipeline PipelineForBlend(MeshPipelineSet pipelines, TranslucencyKind blend) =>
blend switch
{
@ -551,6 +922,95 @@ public sealed unsafe partial class WbDrawDispatcher
return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount);
}
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)));
if (!_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
@ -593,6 +1053,11 @@ 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;
@ -611,6 +1076,7 @@ public sealed unsafe partial class WbDrawDispatcher
// 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)
@ -622,6 +1088,8 @@ 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

@ -505,6 +505,11 @@ public sealed partial class WbDrawDispatcher : IDisposable
// Mechanically a clone of _clipSlotData.
private uint[] _indoorData = new uint[256];
// #226: per-instance retail detail category (binding=9), parallel to the
// transform buffer. 1 = building shell; 0 = ordinary object. Landscape
// and generic objects are intentionally never enabled by the retail caller.
private uint[] _detailCategoryData = new uint[256];
// #188: per-instance opacity multiplier (binding=7), one float per
// instance, parallel to the instance data. 1.0 = unmodified (the dat's own
// material/texture alpha, untouched); < 1.0 multiplies the shader's
@ -539,6 +544,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
// (ParentCellId is an EnvCell). Appended to InstanceGroup.IndoorFlags in
// AppendCurrentLightSet; uploaded as binding=6 instanceIndoor[].
private bool _currentEntityIndoor;
private bool _currentEntityBuildingDetail;
private Vector2 _currentEntitySelectionLighting = new(0f, 1f);
// Phase U.3: the SHARED per-cell clip-region SSBO (binding=2) id, owned by
@ -593,13 +599,13 @@ public sealed partial class WbDrawDispatcher : IDisposable
// every existing CPU writer's offsets are unchanged (see
// GpuBindingModel.GpuBatchDataStrideBytes). TextureIndex used to be a
// 64-bit ulong TextureHandle (an ARB_bindless_texture handle, uvec2 in
// GLSL); it is now a slot into the binding=9 handle table
// GLSL); it is now a slot into the device texture table
// (mesh_modern.vert's BatchData.textureIndex / ACDREAM_TEXTURE_HANDLE),
// which is why the struct only needs 4-byte (not 8-byte) packing now.
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private struct BatchData
{
public uint TextureIndex; // slot into the binding=9 handle table
public uint TextureIndex; // slot into the device texture table
public uint Reserved; // pad — keeps TextureLayer/Flags at offsets 8/12
public uint TextureLayer;
public uint Flags;
@ -611,6 +617,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
uint ClipSlot,
InstanceLightSet Lights,
uint Indoor,
uint DetailCategory,
float Opacity,
Vector2 SelectionLighting);
@ -679,6 +686,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
+ (long)_clipSlotData.Length * sizeof(uint)
+ (long)_lightSetData.Length * sizeof(int)
+ (long)_indoorData.Length * sizeof(uint)
+ (long)_detailCategoryData.Length * sizeof(uint)
+ (long)_alphaData.Length * sizeof(float)
+ (long)_selectionLightingData.Length * Unsafe.SizeOf<Vector2>()
+ (long)_batchData.Length * Unsafe.SizeOf<BatchData>()
@ -795,6 +803,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
public void BeginFrame(int frameSlot)
{
_ = frameSlot;
ResetWorldTransformFrame();
if (_groupFrame == long.MaxValue)
throw new InvalidOperationException("Instance-group frame identity was exhausted.");
@ -1619,6 +1628,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
// is constant across the entity's parts/tuples), by the entity's
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
ComputeEntityLightSet(entity);
_currentEntityBuildingDetail = entity.IsBuildingShell;
_currentEntitySelectionLighting =
_selectionLighting?.TryGetLighting(
entity.ServerGuid,
@ -2107,6 +2117,9 @@ public sealed partial class WbDrawDispatcher : IDisposable
if (_indoorData.Length < immediateInstances)
_indoorData = new uint[immediateInstances + 256];
if (_detailCategoryData.Length < immediateInstances)
_detailCategoryData = new uint[immediateInstances + 256];
// #188: per-instance opacity buffer, one float per instance, parallel to
// _clipSlotData / _instanceData. Grown on demand like the others.
if (_alphaData.Length < immediateInstances)
@ -2336,6 +2349,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
_lightSetData,
cursor * LightManager.MaxLightsPerObject);
_indoorData[cursor] = group.IndoorFlags[i];
_detailCategoryData[cursor] = group.DetailCategories[i];
_alphaData[cursor] = group.Opacities[i];
_selectionLightingData[cursor] = group.SelectionLighting[i];
cursor++;
@ -2623,6 +2637,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
hash.Add(lights[lightIndex]);
}
hash.Add(group.IndoorFlags[index]);
hash.Add(group.DetailCategories[index]);
hash.Add(group.Opacities[index]);
hash.Add(group.SelectionLighting[index]);
}
@ -2679,6 +2694,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
group.Slots[i],
group.LightSets[i],
group.IndoorFlags[i],
group.DetailCategories[i],
group.Opacities[i],
group.SelectionLighting[i]));
queue.Submit(
@ -2721,6 +2737,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
WriteMatrix(_instanceData, i * 16, entry.Model);
_clipSlotData[i] = entry.ClipSlot;
_indoorData[i] = entry.Indoor;
_detailCategoryData[i] = entry.DetailCategory;
_alphaData[i] = entry.Opacity;
_selectionLightingData[i] = entry.SelectionLighting;
int lightOffset = i * LightManager.MaxLightsPerObject;
@ -2732,7 +2749,11 @@ public sealed partial class WbDrawDispatcher : IDisposable
// Campaign V slice V2: table slot, not the raw handle.
TextureIndex = key.TextureSlot.Index,
TextureLayer = key.TextureLayer,
Flags = 0,
// 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.
Flags = 1,
};
_indirectCommands[i] = new DrawElementsIndirectCommand
{
@ -2781,6 +2802,8 @@ public sealed partial class WbDrawDispatcher : IDisposable
_clipSlotData = new uint[count + 256];
if (_indoorData.Length < count)
_indoorData = new uint[count + 256];
if (_detailCategoryData.Length < count)
_detailCategoryData = new uint[count + 256];
if (_alphaData.Length < count)
_alphaData = new float[count + 256];
if (_selectionLightingData.Length < count)
@ -2820,6 +2843,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
int bytesPerUnit = checked(
16 * sizeof(float)
+ sizeof(uint)
+ sizeof(uint)
+ LightManager.MaxLightsPerObject * sizeof(int)
+ sizeof(uint)
+ sizeof(float)
@ -2844,6 +2868,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
_lightSetData = new int[
checked(targetCapacity * LightManager.MaxLightsPerObject)];
_indoorData = new uint[targetCapacity];
_detailCategoryData = new uint[targetCapacity];
_alphaData = new float[targetCapacity];
_selectionLightingData = new Vector2[targetCapacity];
_batchData = new BatchData[targetCapacity];
@ -3271,6 +3296,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
{
grp.LightSets.Add(_currentEntityLightSet);
grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block
grp.DetailCategories.Add(_currentEntityBuildingDetail ? 1u : 0u); // #226
}
private bool ClassifyBatches(
@ -3527,8 +3553,8 @@ public sealed partial class WbDrawDispatcher : IDisposable
/// <summary>
/// Public view of the per-group inputs to <see cref="BuildIndirectArrays"/> — used in tests.
/// Campaign V slice V2: <c>TextureIndex</c> is a slot into the binding=9
/// handle table (was a raw 64-bit bindless <c>TextureHandle</c>).
/// Campaign V slice V2: <c>TextureIndex</c> is a slot into the device's
/// texture table (was a raw 64-bit bindless <c>TextureHandle</c>).
/// </summary>
public readonly record struct IndirectGroupInput(
int IndexCount,
@ -3602,7 +3628,10 @@ public sealed partial class WbDrawDispatcher : IDisposable
TextureIndex = g.TextureIndex,
Reserved = 0,
TextureLayer = g.TextureLayer,
Flags = 0,
// #226: this is the built-mesh path. Retail DrawMesh passes
// curr_detail_surface through RenderMeshSubset for opaque,
// ClipMap, alpha, additive and inverse-alpha subsets alike.
Flags = 1u,
};
if (IsOpaque(g.Translucency))
@ -3631,6 +3660,85 @@ public sealed partial class WbDrawDispatcher : IDisposable
/// </summary>
public static bool IsOpaquePublic(TranslucencyKind t) => IsOpaque(t);
internal readonly record struct DetailCommandRun(
int FirstCommand,
int CommandCount);
/// <summary>
/// Finds the next consecutive run containing at least one building instance
/// per command. Commands with no building instances are never submitted to
/// the detail pipeline; a mixed command remains eligible and relies on the
/// shader's per-instance category filter.
/// </summary>
internal static bool TryGetNextDetailCommandRun(
ReadOnlySpan<DrawElementsIndirectCommand> commands,
ReadOnlySpan<uint> detailCategories,
int searchStart,
int exclusiveEnd,
out DetailCommandRun run)
{
if (searchStart < 0
|| exclusiveEnd < searchStart
|| exclusiveEnd > commands.Length)
{
throw new ArgumentOutOfRangeException(
nameof(searchStart),
"The requested detail-command search range is invalid.");
}
int first = searchStart;
while (first < exclusiveEnd
&& !CommandContainsDetailCategory(
commands[first],
detailCategories))
{
first++;
}
if (first == exclusiveEnd)
{
run = default;
return false;
}
int end = first + 1;
while (end < exclusiveEnd
&& CommandContainsDetailCategory(
commands[end],
detailCategories))
{
end++;
}
run = new DetailCommandRun(first, end - first);
return true;
}
/// <summary>
/// Returns whether an indirect command contains at least one building
/// instance. Retail's detail fallback redraws the exact built-mesh subset
/// immediately after its transparent base subset; this predicate lets the
/// Vulkan arm preserve that adjacency without replaying ordinary objects.
/// </summary>
internal static bool CommandContainsDetailCategory(
DrawElementsIndirectCommand command,
ReadOnlySpan<uint> detailCategories)
{
ulong first = command.BaseInstance;
ulong end = first + command.InstanceCount;
if (end > (ulong)detailCategories.Length)
{
throw new ArgumentOutOfRangeException(
nameof(command),
"The indirect instance range exceeds the detail-category buffer.");
}
for (ulong index = first; index < end; index++)
{
if (detailCategories[(int)index] != 0u)
return true;
}
return false;
}
private static bool IsOpaque(TranslucencyKind t)
=> t == TranslucencyKind.Opaque || t == TranslucencyKind.ClipMap;
@ -3724,6 +3832,10 @@ public sealed partial class WbDrawDispatcher : IDisposable
// cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0.
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.
public readonly List<uint> DetailCategories = new();
// #188: per-instance opacity multiplier, parallel to Matrices.
// Opacities[i] is 1.0=unmodified, or <1.0 while a TransparentPartHook
// fade is in flight for the instance whose matrix is Matrices[i]. At
@ -3753,6 +3865,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
Slots.Clear();
LightSets.Clear();
IndoorFlags.Clear();
DetailCategories.Clear();
Opacities.Clear();
SelectionLighting.Clear();
}
@ -3766,6 +3879,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
Slots.TrimExcess();
LightSets.TrimExcess();
IndoorFlags.TrimExcess();
DetailCategories.TrimExcess();
Opacities.TrimExcess();
SelectionLighting.TrimExcess();
}

View file

@ -0,0 +1,264 @@
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Stable non-ref view of the one pack-on world-transform allocation for a
/// render frame. Shadow and ordinary world commands address different matrix
/// ranges through <c>BaseInstance</c>, but bind this exact buffer, offset, and
/// range. The matrices remain the already-published N.5 values; this type owns
/// no animation or scene projection.
/// </summary>
internal readonly record struct WorldTransformFrameSlice(
long FrameSerial,
IGpuBuffer Buffer,
uint BaseOffsetBytes,
uint BindingSizeBytes,
uint FirstInstance,
uint InstanceCount)
{
internal bool IsValidFor(IGpuFrame frame) =>
FrameSerial == frame.Serial
&& Buffer is not null
&& BindingSizeBytes >= checked(
(FirstInstance + InstanceCount) * WorldTransformCapacityPolicy.MatrixBytes)
&& BindingSizeBytes % WorldTransformCapacityPolicy.MatrixBytes == 0u
&& BaseOffsetBytes % WorldTransformCapacityPolicy.MatrixBytes == 0u
&& checked((long)BaseOffsetBytes + BindingSizeBytes) <= Buffer.SizeBytes;
}
/// <summary>
/// Demand-sized policy for the one enhanced-frame pose address space. The
/// 68,395-matrix connected dense row is the bootstrap observation, not a hard
/// ceiling: allocations grow in 64 KiB pages and stop only at the adapter's
/// probed <c>maxStorageBufferRange</c>. Vulkan guarantees that limit is at least
/// 128 MiB (2,097,152 matrices).
/// </summary>
internal static class WorldTransformCapacityPolicy
{
internal const uint MatrixBytes = 64u;
internal const uint ConnectedDenseBootstrapInstances = 68_395u;
internal const uint AllocationQuantumBytes = 64u * 1024u;
internal const uint InitialBindingSizeBytes =
((ConnectedDenseBootstrapInstances * MatrixBytes
+ AllocationQuantumBytes - 1u) / AllocationQuantumBytes)
* AllocationQuantumBytes;
internal const uint VulkanGuaranteedMaxStorageBufferRangeBytes =
128u * 1024u * 1024u;
internal static uint ResolveBindingSizeBytes(
uint requiredInstances,
uint maxStorageBufferRangeBytes)
{
uint maximum = maxStorageBufferRangeBytes
- (maxStorageBufferRangeBytes % MatrixBytes);
ulong requiredBytes = (ulong)requiredInstances * MatrixBytes;
if (maximum < MatrixBytes || requiredBytes > maximum)
{
throw new NotSupportedException(
$"The enhanced frame needs {requiredInstances:N0} world matrices "
+ $"({requiredBytes:N0} bytes), but this adapter exposes only "
+ $"{maximum:N0} bytes through one storage-buffer binding. "
+ "The pack will fail safe rather than split the authoritative pose buffer.");
}
ulong targetBytes = Math.Max(
requiredBytes,
(ulong)ConnectedDenseBootstrapInstances * MatrixBytes);
ulong growthBytes = checked(
((targetBytes + AllocationQuantumBytes - 1u)
/ AllocationQuantumBytes)
* AllocationQuantumBytes);
// An implementation is allowed to expose a non-page-aligned maximum.
// Use all of it when it is below the preferred growth page, provided
// the current demand still fits.
return (uint)Math.Min(growthBytes, maximum);
}
internal static void ValidateBindingSizeBytes(
uint bindingSizeBytes,
uint requiredInstances,
uint maxStorageBufferRangeBytes)
{
ulong requiredBytes = (ulong)requiredInstances * MatrixBytes;
if (bindingSizeBytes < requiredBytes
|| bindingSizeBytes % MatrixBytes != 0u
|| bindingSizeBytes > maxStorageBufferRangeBytes)
{
throw new ArgumentOutOfRangeException(
nameof(bindingSizeBytes),
bindingSizeBytes,
$"A shared world-transform binding must be matrix-aligned, contain "
+ $"all {requiredInstances:N0} matrices, and not exceed the adapter's "
+ $"{maxStorageBufferRangeBytes:N0}-byte storage range.");
}
}
}
/// <summary>
/// Frame-local address allocator over the pack-on N.5 transform block. The
/// backing buffer may be a frame-ring allocation or the active pack's retained
/// flight-slot arena. It publishes the prepared shadow prefix once and then
/// only appends already-built ordinary world matrices. It deliberately has no
/// scene, animation, or transform-derivation dependency.
/// </summary>
internal sealed class WorldTransformFrameArena
{
private WorldTransformFrameSlice _allocation;
private uint _usedBytes;
internal bool IsActive => _allocation.Buffer is not null;
internal uint UsedInstances => _usedBytes / 64u;
internal WorldTransformFrameSlice Begin(
IGpuFrame frame,
ReadOnlySpan<Matrix4x4> transforms) => Begin(
frame,
transforms,
WorldTransformCapacityPolicy.ResolveBindingSizeBytes(
checked((uint)transforms.Length),
WorldTransformCapacityPolicy.VulkanGuaranteedMaxStorageBufferRangeBytes));
internal WorldTransformFrameSlice Begin(
IGpuFrame frame,
ReadOnlySpan<Matrix4x4> transforms,
uint bindingSizeBytes)
{
ArgumentNullException.ThrowIfNull(frame);
ResetIfStale(frame.Serial);
if (IsActive)
{
throw new InvalidOperationException(
"The directional-shadow transform frame was already published.");
}
uint byteCount = checked((uint)(transforms.Length * WorldTransformCapacityPolicy.MatrixBytes));
if (bindingSizeBytes < byteCount
|| bindingSizeBytes % WorldTransformCapacityPolicy.MatrixBytes != 0u)
throw new ArgumentOutOfRangeException(nameof(bindingSizeBytes));
GpuRingAllocation allocation = frame.AllocateRing(
checked((int)bindingSizeBytes),
GpuRingUsage.Storage);
if (!transforms.IsEmpty)
MemoryMarshal.AsBytes(transforms).CopyTo(allocation.Data);
_allocation = new WorldTransformFrameSlice(
frame.Serial,
allocation.Buffer,
allocation.OffsetBytes,
bindingSizeBytes,
FirstInstance: 0,
checked((uint)transforms.Length));
_usedBytes = byteCount;
return _allocation;
}
/// <summary>
/// Activates a pack-owned flight-slot arena whose shadow prefix was already
/// published. Ordinary N.5 matrices append after that prefix and bind this
/// exact buffer/range, preserving the one authoritative pose SSBO without
/// copying stable shadow matrices through the frame ring.
/// </summary>
internal WorldTransformFrameSlice BeginRetained(
IGpuFrame frame,
in WorldTransformFrameSlice shadowPrefix)
{
ArgumentNullException.ThrowIfNull(frame);
ResetIfStale(frame.Serial);
if (IsActive)
{
throw new InvalidOperationException(
"The directional-shadow transform frame was already published.");
}
if (!shadowPrefix.IsValidFor(frame)
|| shadowPrefix.FirstInstance != 0
|| shadowPrefix.Buffer.Residency != GpuMemoryResidency.HostWritable
|| !shadowPrefix.Buffer.HostWritesAreCoherent
|| !shadowPrefix.Buffer.Usage.HasFlag(GpuBufferUsage.Storage))
{
throw new ArgumentException(
"The retained shadow prefix must be this frame's host-writable "
+ "matrix-aligned storage arena at base instance zero.",
nameof(shadowPrefix));
}
uint byteCount = checked(
shadowPrefix.InstanceCount * WorldTransformCapacityPolicy.MatrixBytes);
if (byteCount > shadowPrefix.BindingSizeBytes)
{
throw new ArgumentException(
"The retained shadow prefix exceeds its storage binding.",
nameof(shadowPrefix));
}
_allocation = shadowPrefix;
_usedBytes = byteCount;
return _allocation;
}
internal WorldTransformFrameSlice Append(
IGpuFrame frame,
ReadOnlySpan<Matrix4x4> transforms)
{
ArgumentNullException.ThrowIfNull(frame);
ResetIfStale(frame.Serial);
if (!IsActive)
{
throw new InvalidOperationException(
"The shared world-transform frame has not been published.");
}
uint byteCount = checked(
(uint)(transforms.Length * WorldTransformCapacityPolicy.MatrixBytes));
uint start = _usedBytes;
uint end = checked(start + byteCount);
if (end > _allocation.BindingSizeBytes)
{
throw new InvalidOperationException(
$"The enhanced frame needs {end / WorldTransformCapacityPolicy.MatrixBytes:N0} world matrices; "
+ $"this frame's shared transform binding contains "
+ $"{_allocation.BindingSizeBytes / WorldTransformCapacityPolicy.MatrixBytes:N0}. "
+ "The pack will fail safe rather than bind a second pose buffer.");
}
if (!transforms.IsEmpty)
{
_allocation.Buffer.Upload(
checked((long)_allocation.BaseOffsetBytes + start),
MemoryMarshal.AsBytes(transforms));
}
_usedBytes = end;
return _allocation with
{
FirstInstance = start / WorldTransformCapacityPolicy.MatrixBytes,
InstanceCount = checked((uint)transforms.Length),
};
}
internal bool IsActiveFor(long frameSerial) =>
IsActive && _allocation.FrameSerial == frameSerial;
internal void Cancel(IGpuFrame frame)
{
ArgumentNullException.ThrowIfNull(frame);
if (IsActiveFor(frame.Serial))
Reset();
}
internal void ResetIfStale(long frameSerial)
{
if (IsActive && _allocation.FrameSerial != frameSerial)
Reset();
}
internal void Reset()
{
_allocation = default;
_usedBytes = 0;
}
}