Contract amendment 1 of three, and V4e's content behind it. Plan section 5.5.16
recorded that both particle pipelines draw with per-instance VERTEX attributes
and that the pinned contract could express instanced DRAWING but not instanced
vertex INPUT: one stride, no divisor, one buffer at VertexInputRate.VERTEX. That
is what stopped V4e. This takes the reviewed option (i) - a second vertex
binding with a per-instance rate.
The amendment. GpuVertexLayout grows a per-binding notion (binding index,
stride, input rate) and GpuVertexAttribute names the binding it is fed from,
defaulting to 0; IGpuPassEncoder.BindVertexBuffer takes a binding index. Every
layout written before this slice keeps its exact meaning through
GpuVertexLayout.Interleaved, which is one vertex-rate binding 0 - and
GpuContractTests asserts that as a requirement rather than trusting it. Both
backends carry the rate natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE
on the pipeline, glVertexAttribDivisor recorded once into the pipeline's VAO
where it survives every later attribute rebind.
GpuVertexFormat.UInt1 comes with it, and is necessary to it: particle.vert
declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole
premise is that no shader is edited. Same kind-distinction UByte4UInt was added
for at V4d - GL needs glVertexAttribIPointer, Vulkan needs R32_UINT, and the
float path would reinterpret the value's bits rather than approximate them.
Options (ii) and (iii) were rejected on the record: all ten storage bindings are
spoken for and reusing binding 0 would have the GL particle draw clobber
WbDrawDispatcher's instance array mid-frame (section 5.5.8's hazard in its GL
form); CPU-expanding instances is 5x billboard bandwidth and does not scale to
mesh particles at all.
The arm. ParticleRenderer.Rhi.cs is a SECOND arm per section 5.5.6, not a
replacement - every GL statement in the sibling file is the one it always
issued. Five pipelines replace the imperative glBlendFunc switch (two billboard
blends, three mesh blends) because core Vulkan 1.3 does not make blend dynamic.
The per-flight VAO/VBO pool disappears because every ring allocation inside a
frame is already distinct memory that lives until the frame retires. The
binding-9 table is not bound at all - the device owns the table and the encoder
binds set 2. The pass is BORROWED from IWorldPassScope. Depth tests but does not
write, compare is Less and alpha-to-coverage is off, which is the ambient GL
state particles have always drawn under rather than a choice. Everything above
the submission seam - emitter iteration, retail distance ordering, the
deferred-alpha handoff, billboard axis construction, blend resolution - is the
same CPU code on both arms.
The first Vulkan particle frame threw rather than drew, which is the second
defect of the compiles-clean class this slice found by running:
TextureCache.AcquireParticleTexture is bindless-only, so the standalone particle
texture cache did not exist on a backend without GL. It exists on both arms now.
Everything about it that matters - sharing equivalent surfaces between emitter
owners, the bounded unowned LRU, retirement behind the frame-flight fence - is
already backend-neutral; only how one entry is created and destroyed differs,
which is what IStandaloneBindlessTextureBackend is for. The RHI arm creates the
image through IGpuDevice.CreateTexture with a real sampler and releases the
table slot before the image, which is the GL arm's order and for the same
reason. The composite cache stays GL-only: it serves entity appearance, not
particles.
The durability fix V6k earned. That slice found the sky declaring a 32-byte
stride against a 36-byte AcDream.Core.Terrain.Vertex - the record carries a
TerrainLayer no sky attribute names - and noted that every .Rhi.cs arm restates
a CPU record's footprint from memory while only sky had a test.
RhiVertexLayoutStrideTests is that test for the rest: world mesh, terrain, sky,
retained-UI sprite, debug line, and both particle bindings, each asserted
against the record or the producer's own float count, plus two sweeps over all
seven for attributes that reach past their stride or name an undeclared binding.
Four private layouts became internal to be assertable; nothing else about them
moved.
Gates. Release build green. App tests 4,121/3 skips (4,109 baseline plus three
contract tests and nine layout tests); complete Release suite 9,184/5. Strict GL
offline pixel gate against 08ffe141: 3.20e-05, 18 differing pixels of 563,200,
inside the documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the
desktop witness and 3/3 on the client capture. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings, a captured world frame that still draws terrain,
blending, roads, water, statics, scenery, sky and the complete retained UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
327 lines
14 KiB
C#
327 lines
14 KiB
C#
using System.Numerics;
|
|
using System.Runtime.InteropServices;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.Core.Lighting;
|
|
using DatReaderWriter.Enums;
|
|
|
|
namespace AcDream.App.Rendering.Wb;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6j: the dungeon-shell renderer's RHI submission arm.
|
|
///
|
|
/// <para>V4c's content, re-landed as a second arm rather than a replacement —
|
|
/// see <see cref="AcDream.App.Rendering.TerrainModernRenderer"/>'s RHI file for
|
|
/// why the fork exists and where it is confined.</para>
|
|
///
|
|
/// <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>
|
|
/// </summary>
|
|
public sealed unsafe partial class EnvCellRenderer
|
|
{
|
|
private readonly IGpuDevice? _device;
|
|
private readonly ICurrentGpuFrameSource? _frames;
|
|
private readonly IWorldPassScope? _scope;
|
|
private IGpuPipeline? _opaquePipeline;
|
|
private IGpuPipeline? _alphaPipeline;
|
|
private IGpuPipeline? _additivePipeline;
|
|
|
|
/// <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
|
|
/// and no <c>Shader</c> to hand in.
|
|
/// </summary>
|
|
internal EnvCellRenderer(
|
|
IGpuDevice device,
|
|
ICurrentGpuFrameSource frames,
|
|
IWorldPassScope scope,
|
|
ObjectMeshManager meshManager,
|
|
WbFrustum frustum)
|
|
{
|
|
_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));
|
|
|
|
_opaquePipeline = CreateShellPipeline(
|
|
device, "envcell-opaque", GpuBlendMode.None, depthWrite: true, scope.SampleCount);
|
|
_alphaPipeline = CreateShellPipeline(
|
|
device, "envcell-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, scope.SampleCount);
|
|
_additivePipeline = CreateShellPipeline(
|
|
device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount);
|
|
_initialized = true;
|
|
}
|
|
|
|
/// <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,
|
|
/// back-face culling with clockwise front faces.
|
|
///
|
|
/// <para>Depth compare is <c>Less</c>, not the contract's <c>LessOrEqual</c>
|
|
/// default. 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 CreateShellPipeline(
|
|
IGpuDevice device,
|
|
string name,
|
|
GpuBlendMode blend,
|
|
bool depthWrite,
|
|
int sampleCount) =>
|
|
device.CreatePipeline(new GpuPipelineDescription
|
|
{
|
|
Name = name,
|
|
Shaders = new GpuShaderSet("mesh_modern"),
|
|
VertexLayout = GpuVertexLayout.WorldMesh,
|
|
Topology = GpuPrimitiveTopology.TriangleList,
|
|
Blend = blend,
|
|
Depth = new GpuDepthState(Test: true, Write: depthWrite, GpuCompareOp.Less),
|
|
Cull = GpuCullMode.Back,
|
|
FrontFace = GpuFrontFace.Clockwise,
|
|
AlphaToCoverage = false,
|
|
ColorWrite = true,
|
|
SampleCount = sampleCount,
|
|
});
|
|
|
|
/// <summary>
|
|
/// Writes this pass's sections into the frame ring and records the same
|
|
/// per-group multi-draw runs the GL arm issues, in the same order.
|
|
/// </summary>
|
|
private void SubmitRhi(
|
|
List<InstanceData> allInstances,
|
|
WbRenderPass renderPass,
|
|
int totalDraws,
|
|
int uniqueInstanceCount)
|
|
{
|
|
IWorldPassScope scope = _scope!;
|
|
IGpuPassEncoder encoder = scope.RequireEncoder();
|
|
IGpuFrame frame = _frames!.CurrentFrame
|
|
?? throw new InvalidOperationException(
|
|
"EnvCellRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
|
|
GlobalMeshBuffer mesh = _meshManager.GlobalBuffer
|
|
?? throw new InvalidOperationException("The shared mesh arena is not published.");
|
|
|
|
if (_gpuInstanceTransforms.Length < uniqueInstanceCount)
|
|
{
|
|
Array.Resize(
|
|
ref _gpuInstanceTransforms,
|
|
Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount));
|
|
}
|
|
for (int i = 0; i < uniqueInstanceCount; i++)
|
|
_gpuInstanceTransforms[i] = allInstances[i].Transform;
|
|
|
|
// Phase U.4: per-instance clip slots, laid out parallel to the transforms
|
|
// so instanceClipSlot[BaseInstance + gl_InstanceID] tracks Instances[].
|
|
if (_clipSlotData.Length < uniqueInstanceCount)
|
|
_clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)];
|
|
if (_cellIdToSlot is null
|
|
|| AcDream.Core.Rendering.RenderingDiagnostics.ClipDebugNoShellTrim)
|
|
{
|
|
Array.Clear(_clipSlotData, 0, uniqueInstanceCount);
|
|
}
|
|
else
|
|
{
|
|
for (int i = 0; i < uniqueInstanceCount; i++)
|
|
{
|
|
_clipSlotData[i] =
|
|
_cellIdToSlot.TryGetValue(allInstances[i].CellId, out int slot)
|
|
? (uint)slot
|
|
: 0u;
|
|
}
|
|
}
|
|
|
|
// A7 Fix D (D-2): per-instance 8-int light set, keyed on the cell each
|
|
// shell instance belongs to.
|
|
int lightStride = LightManager.MaxLightsPerObject;
|
|
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
|
|
{
|
|
_lightSetData = new int[Math.Max(
|
|
_lightSetData.Length * 2,
|
|
uniqueInstanceCount * lightStride)];
|
|
}
|
|
for (int i = 0; i < uniqueInstanceCount; i++)
|
|
{
|
|
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
|
|
Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
|
|
}
|
|
|
|
if (renderPass == WbRenderPass.Opaque
|
|
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
|
|
{
|
|
EmitSeamDrawProbe(_renderDrawCalls, allInstances, _seamProbeFilter);
|
|
}
|
|
|
|
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
|
int globalLightUploadCount = lightCount > 0 ? lightCount : 1;
|
|
|
|
var pushConstants = new GpuPushConstants
|
|
{
|
|
ViewProjection = _lastViewProjection,
|
|
DrawIdOffset = 0,
|
|
// A7 Fix D D-3/D-4: EnvCell bake — wrap points, no sun.
|
|
LightingMode = 1,
|
|
RenderPass = (int)renderPass,
|
|
LightDebug = AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode,
|
|
TextureIndexA = 0,
|
|
TextureIndexB = 0,
|
|
ParamA = 0f,
|
|
ParamB = 0f,
|
|
};
|
|
|
|
// Bind the pass's base pipeline first so the ring binds land on a live
|
|
// program; the per-range switches below rebind the mesh with it.
|
|
IGpuPipeline basePipeline = renderPass == WbRenderPass.Transparent
|
|
? _alphaPipeline!
|
|
: _opaquePipeline!;
|
|
BindPipelineWithMesh(encoder, basePipeline, mesh);
|
|
encoder.SetPushConstants(in pushConstants);
|
|
|
|
BindRingSection<Matrix4x4>(
|
|
encoder, frame, GpuBindingModel.StorageInstances,
|
|
_gpuInstanceTransforms.AsSpan(0, uniqueInstanceCount));
|
|
BindRingSection<ModernBatchData>(
|
|
encoder, frame, GpuBindingModel.StorageBatches,
|
|
_modernBatches.AsSpan(0, totalDraws));
|
|
BindRingSection<uint>(
|
|
encoder, frame, GpuBindingModel.StorageClipSlots,
|
|
_clipSlotData.AsSpan(0, uniqueInstanceCount));
|
|
BindRingSection<float>(
|
|
encoder, frame, GpuBindingModel.StorageGlobalLights,
|
|
_globalLightData.AsSpan(
|
|
0,
|
|
globalLightUploadCount * GlobalLightPacker.FloatsPerLight));
|
|
BindRingSection<int>(
|
|
encoder, frame, GpuBindingModel.StorageInstanceLightSets,
|
|
_lightSetData.AsSpan(0, uniqueInstanceCount * lightStride));
|
|
|
|
// The frame-global sections, bound after this renderer's own binds
|
|
// because those binds are what select the descriptor scope.
|
|
AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions(
|
|
encoder, scope.Sections, frame);
|
|
AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting(
|
|
encoder, scope.Sections, frame);
|
|
|
|
GpuRingAllocation commands = frame.AllocateRing(
|
|
totalDraws * sizeof(DrawElementsIndirectCommand),
|
|
GpuRingUsage.Indirect);
|
|
MemoryMarshal.AsBytes(_commands.AsSpan(0, totalDraws)).CopyTo(commands.Data);
|
|
IGpuBuffer commandBuffer = commands.Buffer;
|
|
uint commandBase = commands.OffsetBytes;
|
|
|
|
for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++)
|
|
{
|
|
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
|
|
int groupIndex = drawRange.GroupIndex;
|
|
var cullMode = (CullMode)(groupIndex % 4);
|
|
// Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock
|
|
// uniformly, but the room surfaces need to be visible from inside.
|
|
// Render cell polys double-sided, exactly as the GL arm does.
|
|
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
|
|
|
|
bool isAdditive = groupIndex >= 4;
|
|
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!,
|
|
mesh);
|
|
}
|
|
|
|
// Must follow the pipeline bind: BindPipeline re-issues the
|
|
// pipeline's own cull/front-face/depth-write defaults.
|
|
SetCullMode(encoder, cullMode);
|
|
|
|
pushConstants.RenderPass = isAdditive
|
|
? (int)renderPass | 0x100
|
|
: (int)renderPass;
|
|
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(
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// WB <c>BaseObjectRenderManager.cs:850-866</c> applies CullMode per MDI
|
|
/// group; WB <c>GameScene.cs:843</c> sets FrontFace(CW) globally. Both are
|
|
/// dynamic state in core Vulkan 1.3, so they stay per-run calls.
|
|
/// </summary>
|
|
private static void SetCullMode(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>
|
|
/// Reserves this frame's ring, copies into it, and binds the slice. A
|
|
/// logically empty section still reserves one element so the bound range is
|
|
/// never zero-length — the "bind at least one element so the shader never
|
|
/// reads an unbound SSBO" rule the light buffers already stated.
|
|
/// </summary>
|
|
private static void BindRingSection<T>(
|
|
IGpuPassEncoder encoder,
|
|
IGpuFrame frame,
|
|
uint binding,
|
|
ReadOnlySpan<T> data)
|
|
where T : unmanaged
|
|
{
|
|
int elementBytes = sizeof(T);
|
|
int byteCount = Math.Max(data.Length * elementBytes, elementBytes);
|
|
GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Storage);
|
|
if (!data.IsEmpty)
|
|
data.CopyTo(allocation.AsSpan<T>());
|
|
encoder.BindStorageBuffer(
|
|
binding,
|
|
allocation.Buffer,
|
|
allocation.OffsetBytes,
|
|
(uint)byteCount);
|
|
}
|
|
|
|
private void DisposeRhiResources()
|
|
{
|
|
_opaquePipeline?.Dispose();
|
|
_opaquePipeline = null;
|
|
_alphaPipeline?.Dispose();
|
|
_alphaPipeline = null;
|
|
_additivePipeline?.Dispose();
|
|
_additivePipeline = null;
|
|
}
|
|
}
|