Contract amendment 2 of three, and V4g's remaining half behind it. Plan section
5.5.16 defect 2: PortalDepthMaskRenderer's two-pass punch (#117) is built on
glStencilFunc/glStencilOp/glStencilMask, GpuPipelineDescription carried no
stencil state at all, and nothing else can express it - so the renderer stayed
raw GL, invisible to the Vulkan arm, and V4g's "stencil/depth-mask pipelines"
row could not be written.
The amendment splits the way core Vulkan 1.3 splits. The ENABLE and the
attachment intent are baked: GpuPipelineDescription.StencilTest, false by
default so no pipeline in the tree changed. The per-draw compare, three outcome
ops, reference and both masks are a GpuStencilState that the pipeline carries as
a DEFAULT and IGpuPassEncoder.SetStencil overrides - exactly the split cull
mode, front face and depth write already have, and exactly what
VK_DYNAMIC_STATE_STENCIL_OP/_COMPARE_MASK/_WRITE_MASK/_REFERENCE make dynamic.
The four stencil dynamic states are declared ONLY by a pipeline that tests
stencil: declaring a dynamic state obliges every draw with the pipeline to have
set it, so adding them unconditionally would make every existing pipeline depend
on a call none of them make. GpuStencilOp carries three values because the punch
uses three - Replace marks, Equal gates, Zero self-cleans - and a fourth would
be a facility with no consumer.
The arm. Three pipelines, not one, because depth COMPARE is not dynamic in the
contract and the punch's two passes differ in it: mark tests LEQUAL and writes
no depth, punch tests ALWAYS and writes, seal is ALWAYS + write with no stencil.
All three write no colour, which is what retail's "COLOR-INVISIBLE triangle fan"
means. The fan is expanded to a triangle LIST on the CPU - the contract has no
fan topology and Vulkan's is not portable - which is exact: triangle i is
(v0, v[i+1], v[i+2]), the same triangles in the same order.
portal_depth.{vert,frag} is a new committed shader pair, and this is the ONE
renderer in the campaign whose two arms do not share a source. Its clip planes
have to travel in the TerrainClip uniform block at binding 2, which is already
precisely this shape and already read by terrain_modern.vert and sky.vert - but
on GL that binding is held globally by ClipFrame for terrain, so a portal draw
that rebound it would leave every later terrain draw in the frame reading the
wrong region. The GL arm therefore keeps its inline program.
PortalDepthShaderParityTests is the tripwire: retail's far-Z constant
(0.99999988, from DrawPortalPolyInternal 0x0059bc90), #129's capped mark-bias
expression and the eight-half-plane loop are asserted to appear in both. Both
are deleted at V11. 9/10 shader pairs now compile to SPIR-V.
Two GL-side gaps closed while the state was being extended, both of section 7.1
rule 1's class rather than new work. GlAmbientCapabilityState now saves and
restores the stencil test, function, ops and both masks - the portal punch draws
mid-frame among renderers that are still raw GL and assume the test is off - and
the COLOUR MASK, which had no consumer until a colour-invisible pipeline existed
and whose absence would have blacked out every raw-GL renderer after such a
pass.
PortalTunnelPresentation was re-read and confirmed as V6k left it: it clears
depth and draws into the active viewport, binds no framebuffer of its own, and
needs no port for section 5.4's sake. It remains unported on the Vulkan arm -
the composition uses NullLocalPlayerTeleportPresentation there - which is an
absence on the V7 list, not a defect.
Gates. Release build green. App tests 4,129/3 skips; complete Release suite
9,192/5 (one solution-wide run reported a single App failure that did not
reproduce in two subsequent runs, solution-wide or alone - the documented
rerun-singly flake class). Strict GL offline pixel gate against 08ffe141:
2.31e-05, 13 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.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
304 lines
12 KiB
C#
304 lines
12 KiB
C#
using System.Collections.Immutable;
|
|
using System.Numerics;
|
|
using System.Runtime.InteropServices;
|
|
using AcDream.App.Rendering.Gpu;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6l: the portal depth mask's RHI submission arm — V4g's
|
|
/// remaining half, and the reason the slice grew a stencil dimension.
|
|
///
|
|
/// <para>Plan §5.5.16 defect 2: this renderer's two-pass punch (#117) is built
|
|
/// on <c>glStencilFunc</c>/<c>glStencilOp</c>/<c>glStencilMask</c> and the pinned
|
|
/// <see cref="GpuPipelineDescription"/> carried no stencil state at all, so it
|
|
/// stayed raw GL and was invisible to the Vulkan arm. The reviewed amendment
|
|
/// puts the ENABLE and the attachment intent in the pipeline
|
|
/// (<see cref="GpuPipelineDescription.StencilTest"/>) and the per-draw compare,
|
|
/// ops, reference and masks on <see cref="IGpuPassEncoder.SetStencil"/>, because
|
|
/// core Vulkan 1.3 makes exactly that split dynamic.</para>
|
|
///
|
|
/// <para><b>Three pipelines, not one.</b> Depth COMPARE is not dynamic in the
|
|
/// contract (only depth write is), and the punch's two passes differ in it —
|
|
/// mark tests <c>LEQUAL</c> and writes no depth, punch tests <c>ALWAYS</c> and
|
|
/// writes. The seal is a third: <c>ALWAYS</c> + write, with no stencil at all.
|
|
/// All three write no colour, which is what retail's "COLOR-INVISIBLE triangle
|
|
/// fan" means.</para>
|
|
///
|
|
/// <para><b>Two other differences from the GL arm.</b> The fan is expanded to a
|
|
/// triangle LIST on the CPU, because <see cref="GpuPrimitiveTopology"/> has no
|
|
/// fan and Vulkan's is not portable; the expansion is exact (v0, vi, vi+1) and
|
|
/// rasterises the same triangles in the same order. And the clip planes travel
|
|
/// in the <c>TerrainClip</c> uniform block at binding 2 rather than as a loose
|
|
/// <c>vec4[8]</c> array, because Vulkan GLSL has no default uniform block — the
|
|
/// block already has precisely this shape and is already read by
|
|
/// <c>terrain_modern.vert</c> and <c>sky.vert</c>.</para>
|
|
/// </summary>
|
|
public sealed partial class PortalDepthMaskRenderer
|
|
{
|
|
private readonly IGpuDevice? _device;
|
|
private readonly ICurrentGpuFrameSource? _frames;
|
|
private readonly IWorldPassScope? _scope;
|
|
private IGpuPipeline? _sealPipeline;
|
|
private IGpuPipeline? _punchMarkPipeline;
|
|
private IGpuPipeline? _punchWritePipeline;
|
|
private bool _rhiFrameStarted;
|
|
|
|
/// <summary>One position per vertex — the only attribute <c>portal_depth.vert</c> reads.</summary>
|
|
internal static GpuVertexLayout PortalVertexLayout { get; } = GpuVertexLayout.Interleaved(
|
|
strideBytes: 3 * sizeof(float),
|
|
ImmutableArray.Create(
|
|
new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0)));
|
|
|
|
/// <summary>
|
|
/// The stencil reference the mark pass writes and the punch pass gates on.
|
|
/// Retail has no equivalent — the whole stencil pass is acdream's
|
|
/// z-buffered replacement for retail's painter's-order safety (#117) — so
|
|
/// the value is arbitrary and only has to agree with itself.
|
|
/// </summary>
|
|
private const uint PunchStencilReference = 1;
|
|
|
|
/// <summary>
|
|
/// The RHI arm's constructor. No GL context and no inline program: the three
|
|
/// pipelines compile <c>portal_depth</c> from the committed SPIR-V, and the
|
|
/// per-frame fan vertices come from the frame ring.
|
|
/// </summary>
|
|
internal PortalDepthMaskRenderer(
|
|
IGpuDevice device,
|
|
ICurrentGpuFrameSource frames,
|
|
IWorldPassScope scope)
|
|
{
|
|
_device = device ?? throw new ArgumentNullException(nameof(device));
|
|
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
|
|
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
|
|
_resources = new ResourceCleanupGroup();
|
|
|
|
try
|
|
{
|
|
int samples = scope.SampleCount;
|
|
// SEAL: retail maxZ2, bit0 clear, data 0x00820e14 — depth ALWAYS at
|
|
// the polygon's true projected depth, no stencil. It runs
|
|
// immediately after the gated full depth clear, so there is no
|
|
// nearer content to stomp.
|
|
_sealPipeline = CreatePortalPipeline(
|
|
device,
|
|
"portal-depth-seal",
|
|
GpuCompareOp.Always,
|
|
depthWrite: true,
|
|
stencilTest: false,
|
|
GpuStencilState.Default,
|
|
samples);
|
|
// PUNCH pass A: mark stencil where the aperture fan passes a LEQUAL
|
|
// depth test at its (biased) true depth — i.e. where the aperture is
|
|
// actually visible against everything drawn so far.
|
|
_punchMarkPipeline = CreatePortalPipeline(
|
|
device,
|
|
"portal-depth-punch-mark",
|
|
GpuCompareOp.LessOrEqual,
|
|
depthWrite: false,
|
|
stencilTest: true,
|
|
GpuStencilState.Default with
|
|
{
|
|
Compare = GpuCompareOp.Always,
|
|
Fail = GpuStencilOp.Keep,
|
|
DepthFail = GpuStencilOp.Keep,
|
|
Pass = GpuStencilOp.Replace,
|
|
Reference = PunchStencilReference,
|
|
},
|
|
samples);
|
|
// PUNCH pass B: the far-Z write on marked pixels only, zeroing the
|
|
// stencil as it goes so the buffer is self-cleaning.
|
|
_punchWritePipeline = CreatePortalPipeline(
|
|
device,
|
|
"portal-depth-punch-write",
|
|
GpuCompareOp.Always,
|
|
depthWrite: true,
|
|
stencilTest: true,
|
|
GpuStencilState.Default with
|
|
{
|
|
Compare = GpuCompareOp.Equal,
|
|
Fail = GpuStencilOp.Keep,
|
|
DepthFail = GpuStencilOp.Keep,
|
|
Pass = GpuStencilOp.Zero,
|
|
Reference = PunchStencilReference,
|
|
},
|
|
samples);
|
|
}
|
|
catch
|
|
{
|
|
DisposeRhiResources();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static IGpuPipeline CreatePortalPipeline(
|
|
IGpuDevice device,
|
|
string name,
|
|
GpuCompareOp depthCompare,
|
|
bool depthWrite,
|
|
bool stencilTest,
|
|
GpuStencilState stencil,
|
|
int sampleCount) =>
|
|
device.CreatePipeline(new GpuPipelineDescription
|
|
{
|
|
Name = name,
|
|
Shaders = new GpuShaderSet("portal_depth"),
|
|
VertexLayout = PortalVertexLayout,
|
|
Topology = GpuPrimitiveTopology.TriangleList,
|
|
Blend = GpuBlendMode.None,
|
|
Depth = new GpuDepthState(Test: true, Write: depthWrite, depthCompare),
|
|
// Portal fans face either way; the GL arm disables culling for the
|
|
// same reason.
|
|
Cull = GpuCullMode.None,
|
|
FrontFace = GpuFrontFace.CounterClockwise,
|
|
AlphaToCoverage = false,
|
|
// "an alpha-0 fan is no colour" in retail; a colour mask here.
|
|
ColorWrite = false,
|
|
StencilTest = stencilTest,
|
|
Stencil = stencil,
|
|
SampleCount = sampleCount,
|
|
});
|
|
|
|
private void DrawDepthFanRhi(
|
|
ReadOnlySpan<Vector3> worldVerts,
|
|
in Matrix4x4 viewProjection,
|
|
ReadOnlySpan<Vector4> planes,
|
|
bool forceFarZ)
|
|
{
|
|
if (!_rhiFrameStarted)
|
|
throw new InvalidOperationException("BeginFrame must be called before drawing portal depth masks.");
|
|
|
|
int n = Math.Min(worldVerts.Length, MaxFanVerts);
|
|
int planeCount = Math.Min(planes.Length, ClipFrame.MaxPlanes);
|
|
IGpuPassEncoder encoder = _scope!.RequireEncoder();
|
|
IGpuFrame frame = _frames!.CurrentFrame
|
|
?? throw new InvalidOperationException(
|
|
"PortalDepthMaskRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
|
|
|
|
// The fan, expanded exactly: triangle i is (v0, v[i+1], v[i+2]).
|
|
int triangleCount = n - 2;
|
|
int vertexCount = triangleCount * 3;
|
|
GpuRingAllocation vertices = frame.AllocateRing(
|
|
vertexCount * 3 * sizeof(float),
|
|
GpuRingUsage.Vertex);
|
|
Span<float> positions = vertices.AsSpan<float>();
|
|
for (int triangle = 0; triangle < triangleCount; triangle++)
|
|
{
|
|
WritePosition(positions, triangle * 9, worldVerts[0]);
|
|
WritePosition(positions, triangle * 9 + 3, worldVerts[triangle + 1]);
|
|
WritePosition(positions, triangle * 9 + 6, worldVerts[triangle + 2]);
|
|
}
|
|
|
|
// The TerrainClip std140 block: an int count padded to 16 bytes, then
|
|
// eight clip-space half-planes. Every unused plane stays zero, which the
|
|
// shader never reads because it compares the index against the count.
|
|
GpuRingAllocation clip = frame.AllocateRing(
|
|
ClipFrame.TerrainUboBytes,
|
|
GpuRingUsage.Uniform);
|
|
clip.Data.Clear();
|
|
MemoryMarshal.Write(clip.Data, in planeCount);
|
|
Span<Vector4> clipPlanes = MemoryMarshal.Cast<byte, Vector4>(
|
|
clip.Data[ClipFrame.CellClipPlanesOffset..]);
|
|
for (int i = 0; i < planeCount; i++)
|
|
clipPlanes[i] = planes[i];
|
|
|
|
if (!forceFarZ)
|
|
{
|
|
RecordPortalPass(
|
|
encoder,
|
|
_sealPipeline!,
|
|
clip,
|
|
vertices,
|
|
vertexCount,
|
|
in viewProjection,
|
|
renderPass: 0,
|
|
depthBias: 0f);
|
|
return;
|
|
}
|
|
|
|
RecordPortalPass(
|
|
encoder,
|
|
_punchMarkPipeline!,
|
|
clip,
|
|
vertices,
|
|
vertexCount,
|
|
in viewProjection,
|
|
renderPass: 0,
|
|
depthBias: PunchMarkDepthBias);
|
|
RecordPortalPass(
|
|
encoder,
|
|
_punchWritePipeline!,
|
|
clip,
|
|
vertices,
|
|
vertexCount,
|
|
in viewProjection,
|
|
renderPass: 1,
|
|
depthBias: 0f);
|
|
}
|
|
|
|
private static void RecordPortalPass(
|
|
IGpuPassEncoder encoder,
|
|
IGpuPipeline pipeline,
|
|
in GpuRingAllocation clip,
|
|
in GpuRingAllocation vertices,
|
|
int vertexCount,
|
|
in Matrix4x4 viewProjection,
|
|
int renderPass,
|
|
float depthBias)
|
|
{
|
|
encoder.BindPipeline(pipeline);
|
|
encoder.SetPushConstants(new GpuPushConstants
|
|
{
|
|
ViewProjection = viewProjection,
|
|
DrawIdOffset = 0,
|
|
LightingMode = 0,
|
|
// portal_depth.vert's "render pass" IS the seal/punch selector —
|
|
// the GL arm's uForceFarZ, rehomed onto the shared block.
|
|
RenderPass = renderPass,
|
|
LightDebug = 0,
|
|
TextureIndexA = 0,
|
|
TextureIndexB = 0,
|
|
ParamA = depthBias,
|
|
ParamB = PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters,
|
|
});
|
|
encoder.BindUniformBuffer(
|
|
ClipFrame.TerrainClipUboBinding,
|
|
clip.Buffer,
|
|
clip.OffsetBytes,
|
|
(uint)ClipFrame.TerrainUboBytes);
|
|
encoder.BindVertexBuffer(0, vertices.Buffer, vertices.OffsetBytes);
|
|
encoder.Draw((uint)vertexCount, 1, 0, 0);
|
|
}
|
|
|
|
private static void WritePosition(Span<float> destination, int offset, Vector3 position)
|
|
{
|
|
destination[offset] = position.X;
|
|
destination[offset + 1] = position.Y;
|
|
destination[offset + 2] = position.Z;
|
|
}
|
|
|
|
private void DisposeRhiResources()
|
|
{
|
|
List<Exception>? failures = null;
|
|
void Attempt(Action action)
|
|
{
|
|
try { action(); }
|
|
catch (Exception error) { (failures ??= []).Add(error); }
|
|
}
|
|
|
|
Attempt(() => _sealPipeline?.Dispose());
|
|
_sealPipeline = null;
|
|
Attempt(() => _punchMarkPipeline?.Dispose());
|
|
_punchMarkPipeline = null;
|
|
Attempt(() => _punchWritePipeline?.Dispose());
|
|
_punchWritePipeline = null;
|
|
_rhiFrameStarted = false;
|
|
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
"The portal depth mask's RHI resources did not fully release.",
|
|
failures);
|
|
}
|
|
}
|
|
}
|