feat(render): Campaign V slice V6l commit 2 - the portal mask draws on Vulkan

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>
This commit is contained in:
Erik 2026-07-28 17:36:45 +02:00
parent b1ad1d481b
commit eced67d038
25 changed files with 1052 additions and 24 deletions

View file

@ -14,7 +14,11 @@ public sealed class GlRenderStateCacheTests
GpuCullMode.Back,
GpuFrontFace.CounterClockwise,
AlphaToCoverage: false,
ColorWrite: true);
ColorWrite: true,
// Slice V6l: the stencil dimension. Off is what every pipeline in the
// tree but the portal depth mask asks for.
StencilTest: false,
GpuStencilState.Default);
[Fact]
public void FirstApplyReportsEveryDimensionChanged()
@ -55,6 +59,34 @@ public sealed class GlRenderStateCacheTests
Assert.False(changes.FrontFace);
Assert.False(changes.AlphaToCoverage);
Assert.False(changes.ColorWrite);
Assert.False(changes.StencilTest);
Assert.False(changes.Stencil);
}
[Fact]
public void ChangingOnlyTheStencilValuesReportsOnlyThatDimension()
{
// Slice V6l. #117's portal punch changes compare, ops, reference and
// masks between its mark pass and its punch pass while the test stays
// enabled, so the two stencil dimensions have to move independently.
var cache = new GlRenderStateCache();
cache.Apply(Default() with { StencilTest = true });
GlRenderStateChanges changes = cache.Apply(Default() with
{
StencilTest = true,
Stencil = GpuStencilState.Default with
{
Compare = GpuCompareOp.Equal,
Pass = GpuStencilOp.Zero,
Reference = 1,
},
});
Assert.True(changes.Stencil);
Assert.False(changes.StencilTest);
Assert.False(changes.DepthTest);
Assert.False(changes.ColorWrite);
}
[Fact]

View file

@ -190,6 +190,72 @@ public sealed class GpuContractTests
Assert.Equal(GpuTextureFormat.Rgba8UnormRenderTarget, description.ColorFormat);
}
[Fact]
public void APipelineCanDeclareThatItUsesTheStencilAspect()
{
// Campaign V slice V6l. Issue #117's portal punch is a two-pass
// stencil operation — mark where the aperture wins a depth test, then
// write the far-Z punch only on marked pixels and zero the stencil as it
// goes — and the V0 contract carried no stencil state at all, so
// PortalDepthMaskRenderer stayed raw GL and V4g's "stencil/depth-mask
// pipelines" row could not be written (plan §5.5.16 defect 2).
var description = new GpuPipelineDescription
{
Name = "contract-stencil",
Shaders = new GpuShaderSet("portal_depth"),
VertexLayout = GpuVertexLayout.None,
};
// Off by default, so no pipeline written before this slice changed.
Assert.False(description.StencilTest);
Assert.Equal(GpuStencilState.Default, description.Stencil);
Assert.Equal(GpuCompareOp.Always, GpuStencilState.Default.Compare);
Assert.Equal(GpuStencilOp.Keep, GpuStencilState.Default.Pass);
GpuPipelineDescription punch = description with
{
StencilTest = true,
Stencil = GpuStencilState.Default with
{
Compare = GpuCompareOp.Equal,
Pass = GpuStencilOp.Zero,
Reference = 1,
},
};
Assert.True(punch.StencilTest);
Assert.Equal(GpuCompareOp.Equal, punch.Stencil.Compare);
Assert.Equal(GpuStencilOp.Zero, punch.Stencil.Pass);
Assert.False(description.StencilTest);
}
[Fact]
public void EveryStencilOperationThePortalPunchNeedsIsRepresentable()
{
// Replace marks, Equal gates, Zero self-cleans. Nothing else in the tree
// touches stencil, and a fourth value would be a facility with no
// consumer rather than completeness.
Assert.Equal(3, Enum.GetValues<GpuStencilOp>().Length);
Assert.Contains(GpuStencilOp.Keep, Enum.GetValues<GpuStencilOp>());
Assert.Contains(GpuStencilOp.Zero, Enum.GetValues<GpuStencilOp>());
Assert.Contains(GpuStencilOp.Replace, Enum.GetValues<GpuStencilOp>());
}
[Fact]
public void TheDepthStencilAttachmentFormatCarriesAStencilAspect()
{
// The punch has nowhere to mark without one. The V5 capability gate
// prefers D32_SFLOAT_S8_UINT and falls back to D24_UNORM_S8_UINT rather
// than taking a depth-only format for exactly this reason, and the
// backbuffer pass clears both aspects through one ClearDepthStencil.
GpuPassDescription pass = GpuPassDescription.BackbufferClear(
"world",
Vector4.Zero,
sampleCount: 4);
Assert.Equal(0u, pass.Depth!.Value.ClearStencil);
Assert.Equal(GpuLoadOp.Clear, pass.Depth!.Value.Load);
Assert.Equal(GpuTextureFormat.Depth24Stencil8, GpuTextureFormat.Depth24Stencil8);
}
[Fact]
public void UniformBindingsDoNotCollide()
{

View file

@ -31,6 +31,7 @@ internal sealed record GpuRecordedUniformBind(uint Binding, string BufferName, u
: GpuRecordedCall;
internal sealed record GpuRecordedVertexBind(uint Binding, string BufferName, uint OffsetBytes) : GpuRecordedCall;
internal sealed record GpuRecordedStencil(GpuStencilState Stencil) : GpuRecordedCall;
internal sealed record GpuRecordedIndexBind(string BufferName, uint OffsetBytes, GpuIndexType IndexType)
: GpuRecordedCall;
@ -374,6 +375,8 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass
public void SetFrontFace(GpuFrontFace frontFace) => device.Record(new GpuRecordedFrontFace(frontFace));
public void SetStencil(in GpuStencilState stencil) => device.Record(new GpuRecordedStencil(stencil));
public void SetDepthWrite(bool enabled) => device.Record(new GpuRecordedDepthWrite(enabled));
public void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance) =>

View file

@ -0,0 +1,79 @@
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Campaign V slice V6l: the portal depth mask is the one renderer whose two arms
/// do NOT share a shader source, and this is the tripwire that stops them
/// drifting.
///
/// <para>Every other RHI arm in the campaign compiles the same GLSL file the GL
/// arm does. This one cannot: its clip planes would have to travel in the
/// <c>TerrainClip</c> uniform block at binding 2, and on GL that binding is held
/// globally by <c>ClipFrame</c> for terrain — a portal draw that rebound it would
/// leave every later terrain draw in the frame reading the wrong region. So the
/// GL arm keeps its inline program, the RHI arm compiles
/// <c>Rendering/Shaders/portal_depth.vert</c>, and the numbers that decide where
/// depth lands are asserted to appear in both.</para>
///
/// <para>Deleted with the GL arm at V11, when the inline string goes.</para>
/// </summary>
public class PortalDepthShaderParityTests
{
private static string PortalDepthVertexSource() =>
File.ReadAllText(Path.Combine(
AppContext.BaseDirectory,
"Rendering",
"Shaders",
"portal_depth.vert"));
[Fact]
public void BothArmsPunchWithRetailsFarZConstant()
{
// 0.99999988 is retail's own value at the tail of
// D3DPolyRender::DrawPortalPolyInternal (0x0059bc90). It decides where
// the punched depth lands, so the two arms agreeing on it is the whole
// point of this file.
const string FarZ = "0.99999988";
Assert.Contains(FarZ, PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal);
Assert.Contains(FarZ, PortalDepthVertexSource(), StringComparison.Ordinal);
}
[Fact]
public void BothArmsApplyTheSameEyeCappedMarkBias()
{
// #129's capped bias: min(bias, cap / max(w*w, 1e-6)), then z -= bias*w.
// Spelled with different identifier names on the two arms — the RHI arm
// reads the shared push block's uParamA/uParamB — so the assertion is on
// the SHAPE that decides the result rather than on the text.
string rhi = PortalDepthVertexSource();
Assert.Contains("max(clipPos.w * clipPos.w, 1e-6)", PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal);
Assert.Contains("max(clipPos.w * clipPos.w, 1e-6)", rhi, StringComparison.Ordinal);
Assert.Contains("clipPos.z -= biasNdc * clipPos.w", PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal);
Assert.Contains("clipPos.z -= biasNdc * clipPos.w", rhi, StringComparison.Ordinal);
}
[Fact]
public void BothArmsClipAgainstEightHalfPlanes()
{
// The plane budget is ClipFrame.MaxPlanes and GL's guaranteed
// GL_MAX_CLIP_DISTANCES; a shader that looped to a different number
// would clip a differently-shaped region than the one the CPU published.
Assert.Equal(8, ClipFrame.MaxPlanes);
Assert.Contains("for (int i = 0; i < 8; i++)", PortalDepthMaskRenderer.VertSrc, StringComparison.Ordinal);
Assert.Contains("for (int i = 0; i < 8; i++)", PortalDepthVertexSource(), StringComparison.Ordinal);
}
[Fact]
public void TheRhiArmReadsTheClipPlanesFromTheSharedTerrainClipBlock()
{
string rhi = PortalDepthVertexSource();
Assert.Contains("uniform TerrainClip", rhi, StringComparison.Ordinal);
Assert.Contains("binding = 2", rhi, StringComparison.Ordinal);
// And the block is the one ClipFrame already packs for terrain and sky:
// an int count padded to 16 bytes, then eight vec4 planes.
Assert.Equal(144, ClipFrame.TerrainUboBytes);
Assert.Equal(2u, ClipFrame.TerrainClipUboBinding);
}
}

View file

@ -130,7 +130,19 @@ public sealed class TextRendererFailureSafetyTests
uint ArrayBuffer,
TextureUnit ActiveUnit,
uint Texture0,
uint Texture2);
uint Texture2,
bool StencilTest,
StencilFunction StencilFunc,
int StencilReference,
uint StencilValueMask,
uint StencilWriteMask,
Silk.NET.OpenGL.StencilOp StencilFail,
Silk.NET.OpenGL.StencilOp StencilDepthFail,
Silk.NET.OpenGL.StencilOp StencilPass,
bool ColorMaskRed,
bool ColorMaskGreen,
bool ColorMaskBlue,
bool ColorMaskAlpha);
private sealed class RecordingGlState : IGlAmbientStateApi
{
@ -150,6 +162,17 @@ public sealed class TextRendererFailureSafetyTests
public TextureUnit ActiveUnit { get; set; }
public Dictionary<TextureUnit, uint> TextureBindings { get; } = [];
// Slice V6l added the stencil and colour-mask dimensions to the ambient
// save/restore, for the portal depth mask's sake. GL's own defaults.
public StencilFunction StencilFuncValue { get; set; } = StencilFunction.Always;
public int StencilReference { get; set; }
public uint StencilValueMask { get; set; } = 0xFFFFFFFFu;
public uint StencilWriteMaskValue { get; set; } = 0xFFFFFFFFu;
public Silk.NET.OpenGL.StencilOp StencilFailOp { get; set; } = Silk.NET.OpenGL.StencilOp.Keep;
public Silk.NET.OpenGL.StencilOp StencilDepthFailOp { get; set; } = Silk.NET.OpenGL.StencilOp.Keep;
public Silk.NET.OpenGL.StencilOp StencilPassOp { get; set; } = Silk.NET.OpenGL.StencilOp.Keep;
public bool[] ColorMaskValue { get; } = [true, true, true, true];
public StateSnapshot Capture() => new(
IsEnabled(EnableCap.DepthTest),
IsEnabled(EnableCap.Blend),
@ -169,7 +192,19 @@ public sealed class TextRendererFailureSafetyTests
ArrayBuffer,
ActiveUnit,
TextureBindings.GetValueOrDefault(TextureUnit.Texture0),
TextureBindings.GetValueOrDefault(TextureUnit.Texture2));
TextureBindings.GetValueOrDefault(TextureUnit.Texture2),
IsEnabled(EnableCap.StencilTest),
StencilFuncValue,
StencilReference,
StencilValueMask,
StencilWriteMaskValue,
StencilFailOp,
StencilDepthFailOp,
StencilPassOp,
ColorMaskValue[0],
ColorMaskValue[1],
ColorMaskValue[2],
ColorMaskValue[3]);
public bool IsEnabled(EnableCap capability) =>
_capabilities.GetValueOrDefault(capability);
@ -189,9 +224,45 @@ public sealed class TextRendererFailureSafetyTests
GetPName.ActiveTexture => (int)ActiveUnit,
GetPName.TextureBinding2D =>
(int)TextureBindings.GetValueOrDefault(ActiveUnit),
GetPName.StencilFunc => (int)StencilFuncValue,
GetPName.StencilRef => StencilReference,
GetPName.StencilValueMask => (int)StencilValueMask,
GetPName.StencilWritemask => (int)StencilWriteMaskValue,
GetPName.StencilFail => (int)StencilFailOp,
GetPName.StencilPassDepthFail => (int)StencilDepthFailOp,
GetPName.StencilPassDepthPass => (int)StencilPassOp,
_ => throw new ArgumentOutOfRangeException(nameof(parameter)),
};
public bool[] GetColorMask() => (bool[])ColorMaskValue.Clone();
public void StencilFunc(StencilFunction function, int reference, uint mask)
{
StencilFuncValue = function;
StencilReference = reference;
StencilValueMask = mask;
}
public void StencilOp(
Silk.NET.OpenGL.StencilOp fail,
Silk.NET.OpenGL.StencilOp depthFail,
Silk.NET.OpenGL.StencilOp pass)
{
StencilFailOp = fail;
StencilDepthFailOp = depthFail;
StencilPassOp = pass;
}
public void StencilMask(uint mask) => StencilWriteMaskValue = mask;
public void ColorMask(bool red, bool green, bool blue, bool alpha)
{
ColorMaskValue[0] = red;
ColorMaskValue[1] = green;
ColorMaskValue[2] = blue;
ColorMaskValue[3] = alpha;
}
public bool GetBoolean(GetPName parameter) =>
parameter == GetPName.DepthWritemask
? DepthWrite